diff --git a/.github/ISSUE_TEMPLATE/package_config.md b/.github/ISSUE_TEMPLATE/package_config.md new file mode 100644 index 000000000..f6322d0fa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package_config.md @@ -0,0 +1,5 @@ +--- +name: "package:package_config" +about: "Create a bug or file a feature request against package:package_config." +labels: "package:package_config" +--- \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/pool.md b/.github/ISSUE_TEMPLATE/pool.md new file mode 100644 index 000000000..7af32c4a6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/pool.md @@ -0,0 +1,5 @@ +--- +name: "package:pool" +about: "Create a bug or file a feature request against package:pool." +labels: "package:pool" +--- \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/pub_semver.md b/.github/ISSUE_TEMPLATE/pub_semver.md new file mode 100644 index 000000000..c7db9b5c1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/pub_semver.md @@ -0,0 +1,5 @@ +--- +name: "package:pub_semver" +about: "Create a bug or file a feature request against package:pub_semver." +labels: "package:pub_semver" +--- \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/pubspec_parse.md b/.github/ISSUE_TEMPLATE/pubspec_parse.md new file mode 100644 index 000000000..2d6588102 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/pubspec_parse.md @@ -0,0 +1,5 @@ +--- +name: "package:pubspec_parse" +about: "Create a bug or file a feature request against package:pubspec_parse." +labels: "package:pubspec_parse" +--- \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/source_maps.md b/.github/ISSUE_TEMPLATE/source_maps.md new file mode 100644 index 000000000..a1e390a75 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/source_maps.md @@ -0,0 +1,5 @@ +--- +name: "package:source_maps" +about: "Create a bug or file a feature request against package:source_maps." +labels: "package:source_maps" +--- \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/source_span.md b/.github/ISSUE_TEMPLATE/source_span.md new file mode 100644 index 000000000..7dbb3c4f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/source_span.md @@ -0,0 +1,5 @@ +--- +name: "package:source_span" +about: "Create a bug or file a feature request against package:source_span." +labels: "package:source_span" +--- \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/sse.md b/.github/ISSUE_TEMPLATE/sse.md new file mode 100644 index 000000000..17cc488c3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/sse.md @@ -0,0 +1,5 @@ +--- +name: "package:sse" +about: "Create a bug or file a feature request against package:sse." +labels: "package:sse" +--- \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/stack_trace.md b/.github/ISSUE_TEMPLATE/stack_trace.md new file mode 100644 index 000000000..417362b3f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/stack_trace.md @@ -0,0 +1,5 @@ +--- +name: "package:stack_trace" +about: "Create a bug or file a feature request against package:stack_trace." +labels: "package:stack_trace" +--- \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/stream_transform.md b/.github/ISSUE_TEMPLATE/stream_transform.md new file mode 100644 index 000000000..475bd837f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/stream_transform.md @@ -0,0 +1,5 @@ +--- +name: "package:stream_transform" +about: "Create a bug or file a feature request against package:stream_transform." +labels: "package:stream_transform" +--- \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/string_scanner.md b/.github/ISSUE_TEMPLATE/string_scanner.md new file mode 100644 index 000000000..ad89f1b5b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/string_scanner.md @@ -0,0 +1,5 @@ +--- +name: "package:string_scanner" +about: "Create a bug or file a feature request against package:string_scanner." +labels: "package:string_scanner" +--- \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/term_glyph.md b/.github/ISSUE_TEMPLATE/term_glyph.md new file mode 100644 index 000000000..b6a47669c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/term_glyph.md @@ -0,0 +1,5 @@ +--- +name: "package:term_glyph" +about: "Create a bug or file a feature request against package:term_glyph." +labels: "package:term_glyph" +--- \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/test_reflective_loader.md b/.github/ISSUE_TEMPLATE/test_reflective_loader.md new file mode 100644 index 000000000..bde03feb7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/test_reflective_loader.md @@ -0,0 +1,5 @@ +--- +name: "package:test_reflective_loader" +about: "Create a bug or file a feature request against package:test_reflective_loader." +labels: "package:test_reflective_loader" +--- \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/timing.md b/.github/ISSUE_TEMPLATE/timing.md new file mode 100644 index 000000000..38a001578 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/timing.md @@ -0,0 +1,5 @@ +--- +name: "package:timing" +about: "Create a bug or file a feature request against package:timing." +labels: "package:timing" +--- \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/watcher.md b/.github/ISSUE_TEMPLATE/watcher.md new file mode 100644 index 000000000..2578819b1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/watcher.md @@ -0,0 +1,5 @@ +--- +name: "package:watcher" +about: "Create a bug or file a feature request against package:watcher." +labels: "package:watcher" +--- \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml index ea9baeba9..64585f35a 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -80,14 +80,66 @@ - changed-files: - any-glob-to-any-file: 'pkgs/oauth2/**' +'package:package_config': + - changed-files: + - any-glob-to-any-file: 'pkgs/package_config/**' + +'package:pool': + - changed-files: + - any-glob-to-any-file: 'pkgs/pool/**' + +'package:pub_semver': + - changed-files: + - any-glob-to-any-file: 'pkgs/pub_semver/**' + +'package:pubspec_parse': + - changed-files: + - any-glob-to-any-file: 'pkgs/pubspec_parse/**' + 'package:source_map_stack_trace': - changed-files: - any-glob-to-any-file: 'pkgs/source_map_stack_trace/**' +'package:source_maps': + - changed-files: + - any-glob-to-any-file: 'pkgs/source_maps/**' + +'package:source_span': + - changed-files: + - any-glob-to-any-file: 'pkgs/source_span/**' + +'package:sse': + - changed-files: + - any-glob-to-any-file: 'pkgs/sse/**' + +'package:stack_trace': + - changed-files: + - any-glob-to-any-file: 'pkgs/stack_trace/**' + 'package:stream_channel': - changed-files: - any-glob-to-any-file: 'pkgs/stream_channel/**' +'package:stream_transform': + - changed-files: + - any-glob-to-any-file: 'pkgs/stream_transform/**' + +'package:term_glyph': + - changed-files: + - any-glob-to-any-file: 'pkgs/term_glyph/**' + +'package:test_reflective_loader': + - changed-files: + - any-glob-to-any-file: 'pkgs/test_reflective_loader/**' + +'package:timing': + - changed-files: + - any-glob-to-any-file: 'pkgs/timing/**' + 'package:unified_analytics': - changed-files: - any-glob-to-any-file: 'pkgs/unified_analytics/**' + +'package:watcher': + - changed-files: + - any-glob-to-any-file: 'pkgs/watcher/**' diff --git a/.github/workflows/bazel_worker.yaml b/.github/workflows/bazel_worker.yaml index 0eb06dad7..b448219d1 100644 --- a/.github/workflows/bazel_worker.yaml +++ b/.github/workflows/bazel_worker.yaml @@ -36,6 +36,8 @@ jobs: - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 with: sdk: ${{ matrix.sdk }} + - run: dart pub get - run: "dart format --output=none --set-exit-if-changed ." + if: ${{ matrix.sdk == 'dev' }} - name: Test run: ./tool/travis.sh diff --git a/.github/workflows/clock.yaml b/.github/workflows/clock.yaml index aef089513..a09a6010c 100644 --- a/.github/workflows/clock.yaml +++ b/.github/workflows/clock.yaml @@ -5,12 +5,12 @@ on: push: branches: [ main ] paths: - - '.github/workflows/clock.yml' + - '.github/workflows/clock.yaml' - 'pkgs/clock/**' pull_request: branches: [ main ] paths: - - '.github/workflows/clock.yml' + - '.github/workflows/clock.yaml' - 'pkgs/clock/**' schedule: - cron: "0 0 * * 0" diff --git a/.github/workflows/package_config.yaml b/.github/workflows/package_config.yaml new file mode 100644 index 000000000..416ea1a11 --- /dev/null +++ b/.github/workflows/package_config.yaml @@ -0,0 +1,71 @@ +name: package:package_config + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/package_config.yml' + - 'pkgs/package_config/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/package_config.yml' + - 'pkgs/package_config/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/package_config/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + sdk: [3.4, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run tests + run: dart test -p chrome,vm + if: always() && steps.install.outcome == 'success' diff --git a/.github/workflows/pool.yaml b/.github/workflows/pool.yaml new file mode 100644 index 000000000..6d64062a8 --- /dev/null +++ b/.github/workflows/pool.yaml @@ -0,0 +1,78 @@ +name: package:pool + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/pool.yaml' + - 'pkgs/pool/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/pool.yaml' + - 'pkgs/pool/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/pool/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + sdk: [3.4, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm + if: always() && steps.install.outcome == 'success' + - name: Run Chrome tests + run: dart test --platform chrome + if: always() && steps.install.outcome == 'success' + - name: Run Chrome tests - wasm + run: dart test --platform chrome -c dart2wasm + if: always() && steps.install.outcome == 'success' && matrix.sdk == 'dev' diff --git a/.github/workflows/pub_semver.yaml b/.github/workflows/pub_semver.yaml new file mode 100644 index 000000000..ba0db18a3 --- /dev/null +++ b/.github/workflows/pub_semver.yaml @@ -0,0 +1,75 @@ +name: package:pub_semver + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/pub_semver.yaml' + - 'pkgs/pub_semver/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/pub_semver.yaml' + - 'pkgs/pub_semver/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/pub_semver/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + sdk: [3.4, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm + if: always() && steps.install.outcome == 'success' + - name: Run Chrome tests + run: dart test --platform chrome --compiler dart2js,dart2wasm + if: always() && steps.install.outcome == 'success' diff --git a/.github/workflows/pubspec_parse.yaml b/.github/workflows/pubspec_parse.yaml new file mode 100644 index 000000000..ebe705912 --- /dev/null +++ b/.github/workflows/pubspec_parse.yaml @@ -0,0 +1,71 @@ +name: package:pubspec_parse + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/pubspec_parse.yaml' + - 'pkgs/pubspec_parse/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/pubspec_parse.yaml' + - 'pkgs/pubspec_parse/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/pubspec_parse/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + sdk: [3.2, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm --run-skipped + if: always() && steps.install.outcome == 'success' diff --git a/.github/workflows/source_maps.yaml b/.github/workflows/source_maps.yaml new file mode 100644 index 000000000..2ae0f20c5 --- /dev/null +++ b/.github/workflows/source_maps.yaml @@ -0,0 +1,72 @@ +name: package:source_maps + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/source_maps.yaml' + - 'pkgs/source_maps/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/source_maps.yaml' + - 'pkgs/source_maps/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/source_maps/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + sdk: [3.3.0, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm + if: always() && steps.install.outcome == 'success' diff --git a/.github/workflows/source_span.yaml b/.github/workflows/source_span.yaml new file mode 100644 index 000000000..2c2ba0548 --- /dev/null +++ b/.github/workflows/source_span.yaml @@ -0,0 +1,75 @@ +name: package:source_span + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/source_span.yml' + - 'pkgs/source_span/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/source_span.yml' + - 'pkgs/source_span/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/source_span/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + sdk: [3.1.0, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm + if: always() && steps.install.outcome == 'success' + - name: Run Chrome tests + run: dart test --platform chrome + if: always() && steps.install.outcome == 'success' diff --git a/.github/workflows/sse.yaml b/.github/workflows/sse.yaml new file mode 100644 index 000000000..9e2f21260 --- /dev/null +++ b/.github/workflows/sse.yaml @@ -0,0 +1,73 @@ +name: package:sse + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/sse.yaml' + - 'pkgs/sse/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/sse.yaml' + - 'pkgs/sse/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/sse/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + sdk: [3.3, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - uses: nanasess/setup-chromedriver@42cc2998329f041de87dc3cfa33a930eacd57eaa + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm --test-randomize-ordering-seed=random -j 1 + if: always() && steps.install.outcome == 'success' diff --git a/.github/workflows/stack_trace.yaml b/.github/workflows/stack_trace.yaml new file mode 100644 index 000000000..7435967a8 --- /dev/null +++ b/.github/workflows/stack_trace.yaml @@ -0,0 +1,75 @@ +name: package:stack_trace + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/stack_trace.yaml' + - 'pkgs/stack_trace/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/stack_trace.yaml' + - 'pkgs/stack_trace/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/stack_trace/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + sdk: [3.4, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm + if: always() && steps.install.outcome == 'success' + - name: Run browser tests + run: dart test --platform chrome + if: always() && steps.install.outcome == 'success' diff --git a/.github/workflows/stream_transform.yaml b/.github/workflows/stream_transform.yaml new file mode 100644 index 000000000..a36a77660 --- /dev/null +++ b/.github/workflows/stream_transform.yaml @@ -0,0 +1,73 @@ +name: package:stream_transform + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/stream_transform.yaml' + - 'pkgs/stream_transform/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/stream_transform.yaml' + - 'pkgs/stream_transform/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/stream_transform/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + # Bump SDK for Legacy tests when changing min SDK. + sdk: [3.1, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run tests + run: dart test -p chrome,vm --test-randomize-ordering-seed=random + if: always() && steps.install.outcome == 'success' diff --git a/.github/workflows/term_glyph.yaml b/.github/workflows/term_glyph.yaml new file mode 100644 index 000000000..5b3b3209b --- /dev/null +++ b/.github/workflows/term_glyph.yaml @@ -0,0 +1,72 @@ +name: package:term_glyph + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/term_glyph.yaml' + - 'pkgs/term_glyph/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/term_glyph.yaml' + - 'pkgs/term_glyph/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/term_glyph/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + sdk: [3.1, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm + if: always() && steps.install.outcome == 'success' diff --git a/.github/workflows/test_reflective_loader.yaml b/.github/workflows/test_reflective_loader.yaml new file mode 100644 index 000000000..975c97049 --- /dev/null +++ b/.github/workflows/test_reflective_loader.yaml @@ -0,0 +1,43 @@ +name: package:test_reflective_loader + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/test_reflective_loader.yaml' + - 'pkgs/test_reflective_loader/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/test_reflective_loader.yaml' + - 'pkgs/test_reflective_loader/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + +defaults: + run: + working-directory: pkgs/test_reflective_loader/ + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev, 3.1] + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + + - run: dart pub get + - name: dart format + run: dart format --output=none --set-exit-if-changed . + - run: dart analyze --fatal-infos + - run: dart test diff --git a/.github/workflows/timing.yaml b/.github/workflows/timing.yaml new file mode 100644 index 000000000..df77b137e --- /dev/null +++ b/.github/workflows/timing.yaml @@ -0,0 +1,67 @@ +name: package:timing + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/timing.yaml' + - 'pkgs/timing/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/timing.yaml' + - 'pkgs/timing/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/timing/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [3.4, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + run: dart pub get + - run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev, 2.2.0 + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + sdk: [3.4, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + run: dart pub get + - run: dart test --platform vm + if: always() && steps.install.outcome == 'success' diff --git a/.github/workflows/watcher.yaml b/.github/workflows/watcher.yaml new file mode 100644 index 000000000..a04483c4d --- /dev/null +++ b/.github/workflows/watcher.yaml @@ -0,0 +1,71 @@ +name: package:watcher + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/watcher.yaml' + - 'pkgs/watcher/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/watcher.yaml' + - 'pkgs/watcher/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/watcher/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, macos-latest, windows-latest + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + sdk: [3.1, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm + if: always() && steps.install.outcome == 'success' diff --git a/README.md b/README.md index 495f0ea36..e58417bb7 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,22 @@ don't naturally belong to other topic monorepos (like | [json_rpc_2](pkgs/json_rpc_2/) | Utilities to write a client or server using the JSON-RPC 2.0 spec. | [![package issues](https://img.shields.io/badge/package:json_rpc_2-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Ajson_rpc_2) | [![pub package](https://img.shields.io/pub/v/json_rpc_2.svg)](https://pub.dev/packages/json_rpc_2) | | [mime](pkgs/mime/) | Utilities for handling media (MIME) types, including determining a type from a file extension and file contents. | [![package issues](https://img.shields.io/badge/package:mime-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Amime) | [![pub package](https://img.shields.io/pub/v/mime.svg)](https://pub.dev/packages/mime) | | [oauth2](pkgs/oauth2/) | A client library for authenticating with a remote service via OAuth2 on behalf of a user, and making authorized HTTP requests with the user's OAuth2 credentials. | [![package issues](https://img.shields.io/badge/package:oauth2-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aoauth2) | [![pub package](https://img.shields.io/pub/v/oauth2.svg)](https://pub.dev/packages/oauth2) | +| [package_config](pkgs/package_config/) | Support for reading and writing Dart Package Configuration files. | [![package issues](https://img.shields.io/badge/package:package_config-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Apackage_config) | [![pub package](https://img.shields.io/pub/v/package_config.svg)](https://pub.dev/packages/package_config) | +| [pool](pkgs/pool/) | Manage a finite pool of resources. Useful for controlling concurrent file system or network requests. | [![package issues](https://img.shields.io/badge/package:pool-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Apool) | [![pub package](https://img.shields.io/pub/v/pool.svg)](https://pub.dev/packages/pool) | +| [pub_semver](pkgs/pub_semver/) | Versions and version constraints implementing pub's versioning policy. This is very similar to vanilla semver, with a few corner cases. | [![package issues](https://img.shields.io/badge/package:pub_semver-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Apub_semver) | [![pub package](https://img.shields.io/pub/v/pub_semver.svg)](https://pub.dev/packages/pub_semver) | +| [pubspec_parse](pkgs/pubspec_parse/) | Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting. | [![package issues](https://img.shields.io/badge/package:pubspec_parse-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Apubspec_parse) | [![pub package](https://img.shields.io/pub/v/pubspec_parse.svg)](https://pub.dev/packages/pubspec_parse) | | [source_map_stack_trace](pkgs/source_map_stack_trace/) | A package for applying source maps to stack traces. | [![package issues](https://img.shields.io/badge/package:source_map_stack_trace-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Asource_map_stack_trace) | [![pub package](https://img.shields.io/pub/v/source_map_stack_trace.svg)](https://pub.dev/packages/source_map_stack_trace) | +| [source_maps](pkgs/source_maps/) | A library to programmatically manipulate source map files. | [![package issues](https://img.shields.io/badge/package:source_maps-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Asource_maps) | [![pub package](https://img.shields.io/pub/v/source_maps.svg)](https://pub.dev/packages/source_maps) | +| [source_span](pkgs/source_span/) | Provides a standard representation for source code locations and spans. | [![package issues](https://img.shields.io/badge/package:source_span-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Asource_span) | [![pub package](https://img.shields.io/pub/v/source_span.svg)](https://pub.dev/packages/source_span) | +| [sse](pkgs/sse/) | Provides client and server functionality for setting up bi-directional communication through Server Sent Events (SSE) and corresponding POST requests. | [![package issues](https://img.shields.io/badge/package:sse-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Asse) | [![pub package](https://img.shields.io/pub/v/sse.svg)](https://pub.dev/packages/sse) | +| [stack_trace](pkgs/stack_trace/) | A package for manipulating stack traces and printing them readably. | [![package issues](https://img.shields.io/badge/package:stack_trace-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Astack_trace) | [![pub package](https://img.shields.io/pub/v/stack_trace.svg)](https://pub.dev/packages/stack_trace) | | [stream_channel](pkgs/stream_channel/) | An abstraction for two-way communication channels based on the Dart Stream class. | [![package issues](https://img.shields.io/badge/package:stream_channel-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Astream_channel) | [![pub package](https://img.shields.io/pub/v/stream_channel.svg)](https://pub.dev/packages/stream_channel) | +| [stream_transform](pkgs/stream_transform/) | A collection of utilities to transform and manipulate streams. | [![package issues](https://img.shields.io/badge/package:stream_transform-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Astream_transform) | [![pub package](https://img.shields.io/pub/v/stream_transform.svg)](https://pub.dev/packages/stream_transform) | +| [term_glyph](pkgs/term_glyph/) | Useful Unicode glyphs and ASCII substitutes. | [![package issues](https://img.shields.io/badge/package:term_glyph-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aterm_glyph) | [![pub package](https://img.shields.io/pub/v/term_glyph.svg)](https://pub.dev/packages/term_glyph) | +| [test_reflective_loader](pkgs/test_reflective_loader/) | Support for discovering tests and test suites using reflection. | [![package issues](https://img.shields.io/badge/package:test_reflective_loader-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Atest_reflective_loader) | [![pub package](https://img.shields.io/pub/v/test_reflective_loader.svg)](https://pub.dev/packages/test_reflective_loader) | +| [timing](pkgs/timing/) | A simple package for tracking the performance of synchronous and asynchronous actions. | [![package issues](https://img.shields.io/badge/package:timing-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Atiming) | [![pub package](https://img.shields.io/pub/v/timing.svg)](https://pub.dev/packages/timing) | | [unified_analytics](pkgs/unified_analytics/) | A package for logging analytics for all Dart and Flutter related tooling to Google Analytics. | [![package issues](https://img.shields.io/badge/package:unified_analytics-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aunified_analytics) | [![pub package](https://img.shields.io/pub/v/unified_analytics.svg)](https://pub.dev/packages/unified_analytics) | +| [watcher](pkgs/watcher/) | Monitor directories and send notifications when the contents change. | [![package issues](https://img.shields.io/badge/package:watcher-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Awatcher) | [![pub package](https://img.shields.io/pub/v/watcher.svg)](https://pub.dev/packages/watcher) | ## Publishing automation diff --git a/pkgs/bazel_worker/benchmark/benchmark.dart b/pkgs/bazel_worker/benchmark/benchmark.dart index 035e2b848..0a0312203 100644 --- a/pkgs/bazel_worker/benchmark/benchmark.dart +++ b/pkgs/bazel_worker/benchmark/benchmark.dart @@ -12,10 +12,7 @@ Future main() async { var path = 'blaze-bin/some/path/to/a/file/that/is/an/input/$i'; workRequest ..arguments.add('--input=$path') - ..inputs.add(Input( - path: '', - digest: List.filled(70, 0x11), - )); + ..inputs.add(Input(path: '', digest: List.filled(70, 0x11))); } // Serialize it. @@ -24,14 +21,20 @@ Future main() async { print('Request has $length requestBytes.'); // Add the length in front base 128 encoded as in the worker protocol. - requestBytes = - Uint8List.fromList(requestBytes.toList()..insertAll(0, _varInt(length))); + requestBytes = Uint8List.fromList( + requestBytes.toList()..insertAll(0, _varInt(length)), + ); // Split into 10000 byte chunks. var lists = []; for (var i = 0; i < requestBytes.length; i += 10000) { - lists.add(Uint8List.sublistView( - requestBytes, i, min(i + 10000, requestBytes.length))); + lists.add( + Uint8List.sublistView( + requestBytes, + i, + min(i + 10000, requestBytes.length), + ), + ); } // Time `AsyncMessageGrouper` and deserialization. diff --git a/pkgs/bazel_worker/e2e_test/bin/async_worker_in_isolate.dart b/pkgs/bazel_worker/e2e_test/bin/async_worker_in_isolate.dart index a94875d57..285b03d2a 100644 --- a/pkgs/bazel_worker/e2e_test/bin/async_worker_in_isolate.dart +++ b/pkgs/bazel_worker/e2e_test/bin/async_worker_in_isolate.dart @@ -17,7 +17,10 @@ import 'package:e2e_test/forwards_to_isolate_async_worker.dart'; Future main(List args, [SendPort? message]) async { var receivePort = ReceivePort(); await Isolate.spawnUri( - Uri.file('async_worker.dart'), [], receivePort.sendPort); + Uri.file('async_worker.dart'), + [], + receivePort.sendPort, + ); var worker = await ForwardsToIsolateAsyncWorker.create(receivePort); await worker.run(); diff --git a/pkgs/bazel_worker/e2e_test/lib/async_worker.dart b/pkgs/bazel_worker/e2e_test/lib/async_worker.dart index d48d87c8d..55f517134 100644 --- a/pkgs/bazel_worker/e2e_test/lib/async_worker.dart +++ b/pkgs/bazel_worker/e2e_test/lib/async_worker.dart @@ -16,9 +16,6 @@ class ExampleAsyncWorker extends AsyncWorkerLoop { @override Future performRequest(WorkRequest request) async { - return WorkResponse( - exitCode: 0, - output: request.arguments.join('\n'), - ); + return WorkResponse(exitCode: 0, output: request.arguments.join('\n')); } } diff --git a/pkgs/bazel_worker/e2e_test/lib/forwards_to_isolate_async_worker.dart b/pkgs/bazel_worker/e2e_test/lib/forwards_to_isolate_async_worker.dart index bb937b220..a4845cf9e 100644 --- a/pkgs/bazel_worker/e2e_test/lib/forwards_to_isolate_async_worker.dart +++ b/pkgs/bazel_worker/e2e_test/lib/forwards_to_isolate_async_worker.dart @@ -13,9 +13,11 @@ class ForwardsToIsolateAsyncWorker extends AsyncWorkerLoop { final IsolateDriverConnection _isolateDriverConnection; static Future create( - ReceivePort receivePort) async { + ReceivePort receivePort, + ) async { return ForwardsToIsolateAsyncWorker( - await IsolateDriverConnection.create(receivePort)); + await IsolateDriverConnection.create(receivePort), + ); } ForwardsToIsolateAsyncWorker(this._isolateDriverConnection); diff --git a/pkgs/bazel_worker/e2e_test/pubspec.yaml b/pkgs/bazel_worker/e2e_test/pubspec.yaml index 56f00cd4c..7eaa89a16 100644 --- a/pkgs/bazel_worker/e2e_test/pubspec.yaml +++ b/pkgs/bazel_worker/e2e_test/pubspec.yaml @@ -10,6 +10,6 @@ dependencies: dev_dependencies: cli_util: ^0.4.2 - dart_flutter_team_lints: ^1.0.0 + dart_flutter_team_lints: ^3.0.0 path: ^1.8.0 test: ^1.16.0 diff --git a/pkgs/bazel_worker/e2e_test/test/e2e_test.dart b/pkgs/bazel_worker/e2e_test/test/e2e_test.dart index caa813a46..6b79b5edb 100644 --- a/pkgs/bazel_worker/e2e_test/test/e2e_test.dart +++ b/pkgs/bazel_worker/e2e_test/test/e2e_test.dart @@ -12,14 +12,18 @@ import 'package:test/test.dart'; void main() { var dart = p.join(sdkPath, 'bin', 'dart'); - runE2eTestForWorker('sync worker', - () => Process.start(dart, [p.join('bin', 'sync_worker.dart')])); - runE2eTestForWorker('async worker', - () => Process.start(dart, [p.join('bin', 'async_worker.dart')])); runE2eTestForWorker( - 'async worker in isolate', - () => - Process.start(dart, [p.join('bin', 'async_worker_in_isolate.dart')])); + 'sync worker', + () => Process.start(dart, [p.join('bin', 'sync_worker.dart')]), + ); + runE2eTestForWorker( + 'async worker', + () => Process.start(dart, [p.join('bin', 'async_worker.dart')]), + ); + runE2eTestForWorker( + 'async worker in isolate', + () => Process.start(dart, [p.join('bin', 'async_worker_in_isolate.dart')]), + ); } void runE2eTestForWorker(String groupName, SpawnWorker spawnWorker) { diff --git a/pkgs/bazel_worker/example/client.dart b/pkgs/bazel_worker/example/client.dart index 7147fcbf0..326bb180b 100644 --- a/pkgs/bazel_worker/example/client.dart +++ b/pkgs/bazel_worker/example/client.dart @@ -5,10 +5,14 @@ import 'package:bazel_worker/driver.dart'; void main() async { var scratchSpace = await Directory.systemTemp.createTemp(); var driver = BazelWorkerDriver( - () => Process.start(Platform.resolvedExecutable, - [Platform.script.resolve('worker.dart').toFilePath()], - workingDirectory: scratchSpace.path), - maxWorkers: 4); + () => Process.start( + Platform.resolvedExecutable, + [ + Platform.script.resolve('worker.dart').toFilePath(), + ], + workingDirectory: scratchSpace.path), + maxWorkers: 4, + ); var response = await driver.doWork(WorkRequest(arguments: ['foo'])); if (response.exitCode != EXIT_CODE_OK) { print('Worker request failed'); diff --git a/pkgs/bazel_worker/lib/src/async_message_grouper.dart b/pkgs/bazel_worker/lib/src/async_message_grouper.dart index e1f0deaef..8fc47780a 100644 --- a/pkgs/bazel_worker/lib/src/async_message_grouper.dart +++ b/pkgs/bazel_worker/lib/src/async_message_grouper.dart @@ -86,13 +86,18 @@ class AsyncMessageGrouper implements MessageGrouper { // Copy as much as possible from the input buffer. Limit is the // smaller of the remaining length to fill in the message and the // remaining length in the buffer. - var lengthToCopy = min(_message.length - _messagePos, - _inputBuffer.length - _inputBufferPos); + var lengthToCopy = min( + _message.length - _messagePos, + _inputBuffer.length - _inputBufferPos, + ); _message.setRange( - _messagePos, - _messagePos + lengthToCopy, - _inputBuffer.sublist( - _inputBufferPos, _inputBufferPos + lengthToCopy)); + _messagePos, + _messagePos + lengthToCopy, + _inputBuffer.sublist( + _inputBufferPos, + _inputBufferPos + lengthToCopy, + ), + ); _messagePos += lengthToCopy; _inputBufferPos += lengthToCopy; diff --git a/pkgs/bazel_worker/lib/src/driver/driver.dart b/pkgs/bazel_worker/lib/src/driver/driver.dart index 4a780208a..06cf0feb1 100644 --- a/pkgs/bazel_worker/lib/src/driver/driver.dart +++ b/pkgs/bazel_worker/lib/src/driver/driver.dart @@ -44,9 +44,12 @@ class BazelWorkerDriver { /// Factory method that spawns a worker process. final SpawnWorker _spawnWorker; - BazelWorkerDriver(this._spawnWorker, - {int? maxIdleWorkers, int? maxWorkers, int? maxRetries}) - : _maxIdleWorkers = maxIdleWorkers ?? 4, + BazelWorkerDriver( + this._spawnWorker, { + int? maxIdleWorkers, + int? maxWorkers, + int? maxRetries, + }) : _maxIdleWorkers = maxIdleWorkers ?? 4, _maxWorkers = maxWorkers ?? 4, _maxRetries = maxRetries ?? 4; @@ -56,8 +59,10 @@ class BazelWorkerDriver { /// [request] has been actually sent to the worker. This allows the caller /// to determine when actual work is being done versus just waiting for an /// available worker. - Future doWork(WorkRequest request, - {void Function(Future)? trackWork}) { + Future doWork( + WorkRequest request, { + void Function(Future)? trackWork, + }) { var attempt = _WorkAttempt(request, trackWork: trackWork); _workQueue.add(attempt); _runWorkQueue(); @@ -69,9 +74,11 @@ class BazelWorkerDriver { for (var worker in _readyWorkers.toList()) { _killWorker(worker); } - await Future.wait(_spawningWorkers.map((worker) async { - _killWorker(await worker); - })); + await Future.wait( + _spawningWorkers.map((worker) async { + _killWorker(await worker); + }), + ); } /// Runs as many items in [_workQueue] as possible given the number of @@ -88,8 +95,10 @@ class BazelWorkerDriver { if (_workQueue.isEmpty) return; if (_numWorkers == _maxWorkers && _idleWorkers.isEmpty) return; if (_numWorkers > _maxWorkers) { - throw StateError('Internal error, created to many workers. Please ' - 'file a bug at https://github.com/dart-lang/bazel_worker/issues/new'); + throw StateError( + 'Internal error, created to many workers. Please ' + 'file a bug at https://github.com/dart-lang/bazel_worker/issues/new', + ); } // At this point we definitely want to run a task, we just need to decide @@ -137,48 +146,51 @@ class BazelWorkerDriver { void _runWorker(Process worker, _WorkAttempt attempt) { var rescheduled = false; - runZonedGuarded(() async { - var connection = _workerConnections[worker]!; + runZonedGuarded( + () async { + var connection = _workerConnections[worker]!; - connection.writeRequest(attempt.request); - var responseFuture = connection.readResponse(); - if (attempt.trackWork != null) { - attempt.trackWork!(responseFuture); - } - var response = await responseFuture; - - // It is possible for us to complete with an error response due to an - // unhandled async error before we get here. - if (!attempt.responseCompleter.isCompleted) { - if (response.exitCode == EXIT_CODE_BROKEN_PIPE) { + connection.writeRequest(attempt.request); + var responseFuture = connection.readResponse(); + if (attempt.trackWork != null) { + attempt.trackWork!(responseFuture); + } + var response = await responseFuture; + + // It is possible for us to complete with an error response due to an + // unhandled async error before we get here. + if (!attempt.responseCompleter.isCompleted) { + if (response.exitCode == EXIT_CODE_BROKEN_PIPE) { + rescheduled = _tryReschedule(attempt); + if (rescheduled) return; + stderr.writeln('Failed to run request ${attempt.request}'); + response = WorkResponse( + exitCode: EXIT_CODE_ERROR, + output: + 'Invalid response from worker, this probably means it wrote ' + 'invalid output or died.', + ); + } + attempt.responseCompleter.complete(response); + _cleanUp(worker); + } + }, + (e, s) { + // Note that we don't need to do additional cleanup here on failures. If + // the worker dies that is already handled in a generic fashion, we just + // need to make sure we complete with a valid response. + if (!attempt.responseCompleter.isCompleted) { rescheduled = _tryReschedule(attempt); if (rescheduled) return; - stderr.writeln('Failed to run request ${attempt.request}'); - response = WorkResponse( + var response = WorkResponse( exitCode: EXIT_CODE_ERROR, - output: - 'Invalid response from worker, this probably means it wrote ' - 'invalid output or died.', + output: 'Error running worker:\n$e\n$s', ); + attempt.responseCompleter.complete(response); + _cleanUp(worker); } - attempt.responseCompleter.complete(response); - _cleanUp(worker); - } - }, (e, s) { - // Note that we don't need to do additional cleanup here on failures. If - // the worker dies that is already handled in a generic fashion, we just - // need to make sure we complete with a valid response. - if (!attempt.responseCompleter.isCompleted) { - rescheduled = _tryReschedule(attempt); - if (rescheduled) return; - var response = WorkResponse( - exitCode: EXIT_CODE_ERROR, - output: 'Error running worker:\n$e\n$s', - ); - attempt.responseCompleter.complete(response); - _cleanUp(worker); - } - }); + }, + ); } /// Performs post-work cleanup for [worker]. diff --git a/pkgs/bazel_worker/lib/src/driver/driver_connection.dart b/pkgs/bazel_worker/lib/src/driver/driver_connection.dart index b419debbc..80d5c98cf 100644 --- a/pkgs/bazel_worker/lib/src/driver/driver_connection.dart +++ b/pkgs/bazel_worker/lib/src/driver/driver_connection.dart @@ -34,13 +34,16 @@ class StdDriverConnection implements DriverConnection { Future get done => _messageGrouper.done; - StdDriverConnection( - {Stream>? inputStream, StreamSink>? outputStream}) - : _messageGrouper = AsyncMessageGrouper(inputStream ?? stdin), + StdDriverConnection({ + Stream>? inputStream, + StreamSink>? outputStream, + }) : _messageGrouper = AsyncMessageGrouper(inputStream ?? stdin), _outputStream = outputStream ?? stdout; factory StdDriverConnection.forWorker(Process worker) => StdDriverConnection( - inputStream: worker.stdout, outputStream: worker.stdin); + inputStream: worker.stdout, + outputStream: worker.stdin, + ); /// Note: This will attempts to recover from invalid proto messages by parsing /// them as strings. This is a common error case for workers (they print a diff --git a/pkgs/bazel_worker/lib/src/utils.dart b/pkgs/bazel_worker/lib/src/utils.dart index 609b4356a..f67bbac1e 100644 --- a/pkgs/bazel_worker/lib/src/utils.dart +++ b/pkgs/bazel_worker/lib/src/utils.dart @@ -13,8 +13,9 @@ List protoToDelimitedBuffer(GeneratedMessage message) { var delimiterBuffer = CodedBufferWriter(); delimiterBuffer.writeInt32NoTag(messageBuffer.lengthInBytes); - var result = - Uint8List(messageBuffer.lengthInBytes + delimiterBuffer.lengthInBytes); + var result = Uint8List( + messageBuffer.lengthInBytes + delimiterBuffer.lengthInBytes, + ); delimiterBuffer.writeTo(result); messageBuffer.writeTo(result, delimiterBuffer.lengthInBytes); diff --git a/pkgs/bazel_worker/lib/src/worker/async_worker_loop.dart b/pkgs/bazel_worker/lib/src/worker/async_worker_loop.dart index 5182b5598..a95d09a1f 100644 --- a/pkgs/bazel_worker/lib/src/worker/async_worker_loop.dart +++ b/pkgs/bazel_worker/lib/src/worker/async_worker_loop.dart @@ -32,20 +32,20 @@ abstract class AsyncWorkerLoop implements WorkerLoop { var request = await connection.readRequest(); if (request == null) break; var printMessages = StringBuffer(); - response = await runZoned(() => performRequest(request), - zoneSpecification: - ZoneSpecification(print: (self, parent, zone, message) { - printMessages.writeln(); - printMessages.write(message); - })); + response = await runZoned( + () => performRequest(request), + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, message) { + printMessages.writeln(); + printMessages.write(message); + }, + ), + ); if (printMessages.isNotEmpty) { response.output = '${response.output}$printMessages'; } } catch (e, s) { - response = WorkResponse( - exitCode: EXIT_CODE_ERROR, - output: '$e\n$s', - ); + response = WorkResponse(exitCode: EXIT_CODE_ERROR, output: '$e\n$s'); } connection.writeResponse(response); diff --git a/pkgs/bazel_worker/lib/src/worker/sync_worker_loop.dart b/pkgs/bazel_worker/lib/src/worker/sync_worker_loop.dart index a8571058c..51da684ab 100644 --- a/pkgs/bazel_worker/lib/src/worker/sync_worker_loop.dart +++ b/pkgs/bazel_worker/lib/src/worker/sync_worker_loop.dart @@ -30,19 +30,20 @@ abstract class SyncWorkerLoop implements WorkerLoop { var request = connection.readRequest(); if (request == null) break; var printMessages = StringBuffer(); - response = runZoned(() => performRequest(request), zoneSpecification: - ZoneSpecification(print: (self, parent, zone, message) { - printMessages.writeln(); - printMessages.write(message); - })); + response = runZoned( + () => performRequest(request), + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, message) { + printMessages.writeln(); + printMessages.write(message); + }, + ), + ); if (printMessages.isNotEmpty) { response.output = '${response.output}$printMessages'; } } catch (e, s) { - response = WorkResponse( - exitCode: EXIT_CODE_ERROR, - output: '$e\n$s', - ); + response = WorkResponse(exitCode: EXIT_CODE_ERROR, output: '$e\n$s'); } connection.writeResponse(response); diff --git a/pkgs/bazel_worker/lib/src/worker/worker_connection.dart b/pkgs/bazel_worker/lib/src/worker/worker_connection.dart index b395316a3..fd5508e4a 100644 --- a/pkgs/bazel_worker/lib/src/worker/worker_connection.dart +++ b/pkgs/bazel_worker/lib/src/worker/worker_connection.dart @@ -29,13 +29,16 @@ abstract class AsyncWorkerConnection implements WorkerConnection { /// Creates a [StdAsyncWorkerConnection] with the specified [inputStream] /// and [outputStream], unless [sendPort] is specified, in which case /// creates a [SendPortAsyncWorkerConnection]. - factory AsyncWorkerConnection( - {Stream>? inputStream, - StreamSink>? outputStream, - SendPort? sendPort}) => + factory AsyncWorkerConnection({ + Stream>? inputStream, + StreamSink>? outputStream, + SendPort? sendPort, + }) => sendPort == null ? StdAsyncWorkerConnection( - inputStream: inputStream, outputStream: outputStream) + inputStream: inputStream, + outputStream: outputStream, + ) : SendPortAsyncWorkerConnection(sendPort); @override @@ -53,9 +56,10 @@ class StdAsyncWorkerConnection implements AsyncWorkerConnection { final AsyncMessageGrouper _messageGrouper; final StreamSink> _outputStream; - StdAsyncWorkerConnection( - {Stream>? inputStream, StreamSink>? outputStream}) - : _messageGrouper = AsyncMessageGrouper(inputStream ?? stdin), + StdAsyncWorkerConnection({ + Stream>? inputStream, + StreamSink>? outputStream, + }) : _messageGrouper = AsyncMessageGrouper(inputStream ?? stdin), _outputStream = outputStream ?? stdout; @override diff --git a/pkgs/bazel_worker/lib/testing.dart b/pkgs/bazel_worker/lib/testing.dart index 3ae4c1f4d..7aefabbd8 100644 --- a/pkgs/bazel_worker/lib/testing.dart +++ b/pkgs/bazel_worker/lib/testing.dart @@ -72,10 +72,18 @@ class TestStdinAsync implements TestStdin { } @override - StreamSubscription listen(void Function(Uint8List bytes)? onData, - {Function? onError, void Function()? onDone, bool? cancelOnError}) { - return _controller.stream.listen(onData, - onError: onError, onDone: onDone, cancelOnError: cancelOnError); + StreamSubscription listen( + void Function(Uint8List bytes)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return _controller.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); } @override @@ -165,8 +173,9 @@ class TestAsyncWorkerConnection extends StdAsyncWorkerConnection final List responses = []; TestAsyncWorkerConnection( - Stream> inputStream, StreamSink> outputStream) - : super(inputStream: inputStream, outputStream: outputStream); + Stream> inputStream, + StreamSink> outputStream, + ) : super(inputStream: inputStream, outputStream: outputStream); @override void writeResponse(WorkResponse response) { diff --git a/pkgs/bazel_worker/test/driver_test.dart b/pkgs/bazel_worker/test/driver_test.dart index c3978304e..c3db55c66 100644 --- a/pkgs/bazel_worker/test/driver_test.dart +++ b/pkgs/bazel_worker/test/driver_test.dart @@ -23,27 +23,37 @@ void main() { await _doRequests(count: 1); }); - test('can run multiple batches of requests through multiple workers', - () async { - var maxWorkers = 4; - var maxIdleWorkers = 2; - driver = BazelWorkerDriver(MockWorker.spawn, - maxWorkers: maxWorkers, maxIdleWorkers: maxIdleWorkers); - for (var i = 0; i < 10; i++) { - await _doRequests(driver: driver); - expect(MockWorker.liveWorkers.length, maxIdleWorkers); - // No workers should be killed while there is ongoing work, but they - // should be cleaned up once there isn't any more work to do. - expect(MockWorker.deadWorkers.length, - (maxWorkers - maxIdleWorkers) * (i + 1)); - } - }); + test( + 'can run multiple batches of requests through multiple workers', + () async { + var maxWorkers = 4; + var maxIdleWorkers = 2; + driver = BazelWorkerDriver( + MockWorker.spawn, + maxWorkers: maxWorkers, + maxIdleWorkers: maxIdleWorkers, + ); + for (var i = 0; i < 10; i++) { + await _doRequests(driver: driver); + expect(MockWorker.liveWorkers.length, maxIdleWorkers); + // No workers should be killed while there is ongoing work, but they + // should be cleaned up once there isn't any more work to do. + expect( + MockWorker.deadWorkers.length, + (maxWorkers - maxIdleWorkers) * (i + 1), + ); + } + }, + ); test('can run multiple requests through one worker', () async { var maxWorkers = 1; var maxIdleWorkers = 1; - driver = BazelWorkerDriver(MockWorker.spawn, - maxWorkers: maxWorkers, maxIdleWorkers: maxIdleWorkers); + driver = BazelWorkerDriver( + MockWorker.spawn, + maxWorkers: maxWorkers, + maxIdleWorkers: maxIdleWorkers, + ); for (var i = 0; i < 10; i++) { await _doRequests(driver: driver); expect(MockWorker.liveWorkers.length, 1); @@ -52,8 +62,11 @@ void main() { }); test('can run one request through multiple workers', () async { - driver = - BazelWorkerDriver(MockWorker.spawn, maxWorkers: 4, maxIdleWorkers: 4); + driver = BazelWorkerDriver( + MockWorker.spawn, + maxWorkers: 4, + maxIdleWorkers: 4, + ); for (var i = 0; i < 10; i++) { await _doRequests(driver: driver, count: 1); expect(MockWorker.liveWorkers.length, 1); @@ -63,8 +76,11 @@ void main() { test('can run with maxIdleWorkers == 0', () async { var maxWorkers = 4; - driver = BazelWorkerDriver(MockWorker.spawn, - maxWorkers: maxWorkers, maxIdleWorkers: 0); + driver = BazelWorkerDriver( + MockWorker.spawn, + maxWorkers: maxWorkers, + maxIdleWorkers: 0, + ); for (var i = 0; i < 10; i++) { await _doRequests(driver: driver); expect(MockWorker.liveWorkers.length, 0); @@ -77,14 +93,15 @@ void main() { driver = BazelWorkerDriver(MockWorker.spawn, maxWorkers: maxWorkers); var tracking = []; await _doRequests( - driver: driver, - count: 10, - trackWork: (Future response) { - // We should never be tracking more than `maxWorkers` jobs at a time. - expect(tracking.length, lessThan(maxWorkers)); - tracking.add(response); - response.then((_) => tracking.remove(response)); - }); + driver: driver, + count: 10, + trackWork: (Future response) { + // We should never be tracking more than `maxWorkers` jobs at a time. + expect(tracking.length, lessThan(maxWorkers)); + tracking.add(response); + response.then((_) => tracking.remove(response)); + }, + ); }); group('failing workers', () { @@ -93,27 +110,39 @@ void main() { void createDriver({int maxRetries = 2, int numBadWorkers = 2}) { var numSpawned = 0; driver = BazelWorkerDriver( - () async => MockWorker(workerLoopFactory: (MockWorker worker) { - var connection = StdAsyncWorkerConnection( - inputStream: worker._stdinController.stream, - outputStream: worker._stdoutController.sink); - if (numSpawned < numBadWorkers) { - numSpawned++; - return ThrowingMockWorkerLoop( - worker, MockWorker.responseQueue, connection); - } else { - return MockWorkerLoop(MockWorker.responseQueue, - connection: connection); - } - }), - maxRetries: maxRetries); + () async => MockWorker( + workerLoopFactory: (MockWorker worker) { + var connection = StdAsyncWorkerConnection( + inputStream: worker._stdinController.stream, + outputStream: worker._stdoutController.sink, + ); + if (numSpawned < numBadWorkers) { + numSpawned++; + return ThrowingMockWorkerLoop( + worker, + MockWorker.responseQueue, + connection, + ); + } else { + return MockWorkerLoop( + MockWorker.responseQueue, + connection: connection, + ); + } + }, + ), + maxRetries: maxRetries, + ); } test('should retry up to maxRetries times', () async { createDriver(); var expectedResponse = WorkResponse(); - MockWorker.responseQueue.addAll( - [disconnectedResponse, disconnectedResponse, expectedResponse]); + MockWorker.responseQueue.addAll([ + disconnectedResponse, + disconnectedResponse, + expectedResponse, + ]); var actualResponse = await driver!.doWork(WorkRequest()); // The first 2 null responses are thrown away, and we should get the // third one. @@ -125,23 +154,29 @@ void main() { test('should fail if it exceeds maxRetries failures', () async { createDriver(maxRetries: 2, numBadWorkers: 3); - MockWorker.responseQueue.addAll( - [disconnectedResponse, disconnectedResponse, WorkResponse()]); + MockWorker.responseQueue.addAll([ + disconnectedResponse, + disconnectedResponse, + WorkResponse(), + ]); var actualResponse = await driver!.doWork(WorkRequest()); // Should actually get a bad response. expect(actualResponse.exitCode, 15); expect( - actualResponse.output, - 'Invalid response from worker, this probably means it wrote ' - 'invalid output or died.'); + actualResponse.output, + 'Invalid response from worker, this probably means it wrote ' + 'invalid output or died.', + ); expect(MockWorker.deadWorkers.length, 3); }); }); test('handles spawnWorker failures', () async { - driver = BazelWorkerDriver(() async => throw StateError('oh no!'), - maxRetries: 0); + driver = BazelWorkerDriver( + () async => throw StateError('oh no!'), + maxRetries: 0, + ); expect(driver!.doWork(WorkRequest()), throwsA(isA())); }); @@ -156,10 +191,11 @@ void main() { /// Runs [count] of fake work requests through [driver], and asserts that they /// all completed. -Future _doRequests( - {BazelWorkerDriver? driver, - int count = 100, - void Function(Future)? trackWork}) async { +Future _doRequests({ + BazelWorkerDriver? driver, + int count = 100, + void Function(Future)? trackWork, +}) async { // If we create a driver, we need to make sure and terminate it. var terminateDriver = driver == null; driver ??= BazelWorkerDriver(MockWorker.spawn); @@ -167,7 +203,8 @@ Future _doRequests( var responses = List.generate(count, (_) => WorkResponse()); MockWorker.responseQueue.addAll(responses); var actualResponses = await Future.wait( - requests.map((request) => driver!.doWork(request, trackWork: trackWork))); + requests.map((request) => driver!.doWork(request, trackWork: trackWork)), + ); expect(actualResponses, unorderedEquals(responses)); if (terminateDriver) await driver.terminateWorkers(); } @@ -191,9 +228,11 @@ class MockWorkerLoop extends AsyncWorkerLoop { class ThrowingMockWorkerLoop extends MockWorkerLoop { final MockWorker _mockWorker; - ThrowingMockWorkerLoop(this._mockWorker, Queue responseQueue, - AsyncWorkerConnection connection) - : super(responseQueue, connection: connection); + ThrowingMockWorkerLoop( + this._mockWorker, + Queue responseQueue, + AsyncWorkerConnection connection, + ) : super(responseQueue, connection: connection); /// Run the worker loop. The returned [Future] doesn't complete until /// [connection#readRequest] returns `null`. @@ -234,10 +273,13 @@ class MockWorker implements Process { liveWorkers.add(this); var workerLoop = workerLoopFactory != null ? workerLoopFactory(this) - : MockWorkerLoop(responseQueue, + : MockWorkerLoop( + responseQueue, connection: StdAsyncWorkerConnection( - inputStream: _stdinController.stream, - outputStream: _stdoutController.sink)); + inputStream: _stdinController.stream, + outputStream: _stdoutController.sink, + ), + ); workerLoop.run(); } @@ -260,8 +302,10 @@ class MockWorker implements Process { int get pid => throw UnsupportedError('Not needed.'); @override - bool kill( - [ProcessSignal processSignal = ProcessSignal.sigterm, int exitCode = 0]) { + bool kill([ + ProcessSignal processSignal = ProcessSignal.sigterm, + int exitCode = 0, + ]) { if (_killed) return false; () async { await _stdoutController.close(); diff --git a/pkgs/bazel_worker/test/message_grouper_test.dart b/pkgs/bazel_worker/test/message_grouper_test.dart index 475190ed8..fd99911d1 100644 --- a/pkgs/bazel_worker/test/message_grouper_test.dart +++ b/pkgs/bazel_worker/test/message_grouper_test.dart @@ -18,8 +18,10 @@ void main() { }); } -void runTests(TestStdin Function() stdinFactory, - MessageGrouper Function(Stdin) messageGrouperFactory) { +void runTests( + TestStdin Function() stdinFactory, + MessageGrouper Function(Stdin) messageGrouperFactory, +) { late MessageGrouper messageGrouper; late TestStdin stdinStream; @@ -52,16 +54,12 @@ void runTests(TestStdin Function() stdinFactory, }); test('Short message', () async { - await check([ - 5, - 10, - 20, - 30, - 40, - 50 - ], [ - [10, 20, 30, 40, 50] - ]); + await check( + [5, 10, 20, 30, 40, 50], + [ + [10, 20, 30, 40, 50], + ], + ); }); test('Message with 2-byte length', () async { @@ -79,57 +77,44 @@ void runTests(TestStdin Function() stdinFactory, }); test('Multiple messages', () async { - await check([ - 2, - 10, - 20, - 2, - 30, - 40 - ], [ - [10, 20], - [30, 40] - ]); + await check( + [2, 10, 20, 2, 30, 40], + [ + [10, 20], + [30, 40], + ], + ); }); test('Empty message at start', () async { - await check([ - 0, - 2, - 10, - 20 - ], [ - [], - [10, 20] - ]); + await check( + [0, 2, 10, 20], + [ + [], + [10, 20], + ], + ); }); test('Empty message at end', () async { - await check([ - 2, - 10, - 20, - 0 - ], [ - [10, 20], - [] - ]); + await check( + [2, 10, 20, 0], + [ + [10, 20], + [], + ], + ); }); test('Empty message in the middle', () async { - await check([ - 2, - 10, - 20, - 0, - 2, - 30, - 40 - ], [ - [10, 20], - [], - [30, 40] - ]); + await check( + [2, 10, 20, 0, 2, 30, 40], + [ + [10, 20], + [], + [30, 40], + ], + ); }); test('Handles the case when stdin gives an error instead of EOF', () async { diff --git a/pkgs/bazel_worker/test/worker_loop_test.dart b/pkgs/bazel_worker/test/worker_loop_test.dart index 50d21512e..24068b1d8 100644 --- a/pkgs/bazel_worker/test/worker_loop_test.dart +++ b/pkgs/bazel_worker/test/worker_loop_test.dart @@ -11,36 +11,45 @@ import 'package:test/test.dart'; void main() { group('SyncWorkerLoop', () { - runTests(TestStdinSync.new, TestSyncWorkerConnection.new, - TestSyncWorkerLoop.new); + runTests( + TestStdinSync.new, + TestSyncWorkerConnection.new, + TestSyncWorkerLoop.new, + ); }); group('AsyncWorkerLoop', () { - runTests(TestStdinAsync.new, TestAsyncWorkerConnection.new, - TestAsyncWorkerLoop.new); + runTests( + TestStdinAsync.new, + TestAsyncWorkerConnection.new, + TestAsyncWorkerLoop.new, + ); }); group('SyncWorkerLoopWithPrint', () { runTests( - TestStdinSync.new, - TestSyncWorkerConnection.new, - (TestSyncWorkerConnection connection) => - TestSyncWorkerLoop(connection, printMessage: 'Goodbye!')); + TestStdinSync.new, + TestSyncWorkerConnection.new, + (TestSyncWorkerConnection connection) => + TestSyncWorkerLoop(connection, printMessage: 'Goodbye!'), + ); }); group('AsyncWorkerLoopWithPrint', () { runTests( - TestStdinAsync.new, - TestAsyncWorkerConnection.new, - (TestAsyncWorkerConnection connection) => - TestAsyncWorkerLoop(connection, printMessage: 'Goodbye!')); + TestStdinAsync.new, + TestAsyncWorkerConnection.new, + (TestAsyncWorkerConnection connection) => + TestAsyncWorkerLoop(connection, printMessage: 'Goodbye!'), + ); }); } void runTests( - TestStdin Function() stdinFactory, - T Function(Stdin, Stdout) workerConnectionFactory, - TestWorkerLoop Function(T) workerLoopFactory) { + TestStdin Function() stdinFactory, + T Function(Stdin, Stdout) workerConnectionFactory, + TestWorkerLoop Function(T) workerLoopFactory, +) { late TestStdin stdinStream; late TestStdoutStream stdoutStream; late T connection; @@ -63,19 +72,29 @@ void runTests( // Make sure `print` never gets called in the parent zone. var printMessages = []; - await runZoned(() => workerLoop.run(), zoneSpecification: - ZoneSpecification(print: (self, parent, zone, message) { - printMessages.add(message); - })); - expect(printMessages, isEmpty, - reason: 'The worker loop should hide all print calls from the parent ' - 'zone.'); + await runZoned( + () => workerLoop.run(), + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, message) { + printMessages.add(message); + }, + ), + ); + expect( + printMessages, + isEmpty, + reason: 'The worker loop should hide all print calls from the parent ' + 'zone.', + ); expect(connection.responses, hasLength(1)); expect(connection.responses[0], response); if (workerLoop.printMessage != null) { - expect(response.output, endsWith(workerLoop.printMessage!), - reason: 'Print messages should get appended to the response output.'); + expect( + response.output, + endsWith(workerLoop.printMessage!), + reason: 'Print messages should get appended to the response output.', + ); } // Check that a serialized version was written to std out. diff --git a/pkgs/clock/analysis_options.yaml b/pkgs/clock/analysis_options.yaml index 9ee7c2b6a..db6072da7 100644 --- a/pkgs/clock/analysis_options.yaml +++ b/pkgs/clock/analysis_options.yaml @@ -11,4 +11,3 @@ linter: rules: - avoid_private_typedef_functions - avoid_redundant_argument_values - - use_super_parameters diff --git a/pkgs/coverage/analysis_options.yaml b/pkgs/coverage/analysis_options.yaml index 82ce5e0a5..bb1afe05a 100644 --- a/pkgs/coverage/analysis_options.yaml +++ b/pkgs/coverage/analysis_options.yaml @@ -9,14 +9,9 @@ analyzer: linter: rules: - - always_declare_return_types - avoid_slow_async_io - cancel_subscriptions - - comment_references - literal_only_boolean_expressions - prefer_final_locals - sort_constructors_first - sort_unnamed_constructors_first - - test_types_in_equals - - throw_in_finally - - type_annotate_public_apis diff --git a/pkgs/file/CHANGELOG.md b/pkgs/file/CHANGELOG.md index 50c96c49d..3a3969cad 100644 --- a/pkgs/file/CHANGELOG.md +++ b/pkgs/file/CHANGELOG.md @@ -1,3 +1,5 @@ +## 7.0.2-wip + ## 7.0.1 * Update the pubspec repository field to reflect the new package repository. diff --git a/pkgs/file/analysis_options.yaml b/pkgs/file/analysis_options.yaml index 8fbd2e443..d978f811c 100644 --- a/pkgs/file/analysis_options.yaml +++ b/pkgs/file/analysis_options.yaml @@ -1,6 +1 @@ -include: package:lints/recommended.yaml - -analyzer: - errors: - # Allow having TODOs in the code - todo: ignore +include: package:dart_flutter_team_lints/analysis_options.yaml diff --git a/pkgs/file/example/main.dart b/pkgs/file/example/main.dart index 7ca0bc73f..b03b363f0 100644 --- a/pkgs/file/example/main.dart +++ b/pkgs/file/example/main.dart @@ -7,8 +7,8 @@ import 'package:file/memory.dart'; Future main() async { final FileSystem fs = MemoryFileSystem(); - final Directory tmp = await fs.systemTempDirectory.createTemp('example_'); - final File outputFile = tmp.childFile('output'); + final tmp = await fs.systemTempDirectory.createTemp('example_'); + final outputFile = tmp.childFile('output'); await outputFile.writeAsString('Hello world!'); print(outputFile.readAsStringSync()); } diff --git a/pkgs/file/lib/chroot.dart b/pkgs/file/lib/chroot.dart index 56d2bd5d7..6992ad0b4 100644 --- a/pkgs/file/lib/chroot.dart +++ b/pkgs/file/lib/chroot.dart @@ -3,4 +3,6 @@ // BSD-style license that can be found in the LICENSE file. /// A file system that provides a view into _another_ `FileSystem` via a path. +library; + export 'src/backends/chroot.dart'; diff --git a/pkgs/file/lib/file.dart b/pkgs/file/lib/file.dart index cdde9fedd..c2e97b2ee 100644 --- a/pkgs/file/lib/file.dart +++ b/pkgs/file/lib/file.dart @@ -4,5 +4,7 @@ /// Core interfaces containing the abstract `FileSystem` interface definition /// and all associated types used by `FileSystem`. +library; + export 'src/forwarding.dart'; export 'src/interface.dart'; diff --git a/pkgs/file/lib/local.dart b/pkgs/file/lib/local.dart index 74f506e36..5b1e3cd9d 100644 --- a/pkgs/file/lib/local.dart +++ b/pkgs/file/lib/local.dart @@ -4,4 +4,6 @@ /// A local file system implementation. This relies on the use of `dart:io` /// and is thus not suitable for use in the browser. +library; + export 'src/backends/local.dart'; diff --git a/pkgs/file/lib/memory.dart b/pkgs/file/lib/memory.dart index c5705eff9..690b65fa5 100644 --- a/pkgs/file/lib/memory.dart +++ b/pkgs/file/lib/memory.dart @@ -4,5 +4,7 @@ /// An implementation of `FileSystem` that exists entirely in memory with an /// internal representation loosely based on the Filesystem Hierarchy Standard. +library; + export 'src/backends/memory.dart'; export 'src/backends/memory/operations.dart'; diff --git a/pkgs/file/lib/src/backends/chroot.dart b/pkgs/file/lib/src/backends/chroot.dart index 6082e808c..402dbec5b 100644 --- a/pkgs/file/lib/src/backends/chroot.dart +++ b/pkgs/file/lib/src/backends/chroot.dart @@ -2,16 +2,16 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -library file.src.backends.chroot; - import 'dart:convert'; import 'dart:typed_data'; -import 'package:file/file.dart'; -import 'package:file/src/common.dart' as common; -import 'package:file/src/io.dart' as io; import 'package:path/path.dart' as p; +import '../common.dart' as common; +import '../forwarding.dart'; +import '../interface.dart'; +import '../io.dart' as io; + part 'chroot/chroot_directory.dart'; part 'chroot/chroot_file.dart'; part 'chroot/chroot_file_system.dart'; diff --git a/pkgs/file/lib/src/backends/chroot/chroot_directory.dart b/pkgs/file/lib/src/backends/chroot/chroot_directory.dart index 8fec7b198..e09419384 100644 --- a/pkgs/file/lib/src/backends/chroot/chroot_directory.dart +++ b/pkgs/file/lib/src/backends/chroot/chroot_directory.dart @@ -2,18 +2,18 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -part of file.src.backends.chroot; +part of '../chroot.dart'; class _ChrootDirectory extends _ChrootFileSystemEntity with ForwardingDirectory, common.DirectoryAddOnsMixin { - _ChrootDirectory(ChrootFileSystem fs, String path) : super(fs, path); + _ChrootDirectory(super.fs, super.path); factory _ChrootDirectory.wrapped( ChrootFileSystem fs, Directory delegate, { bool relative = false, }) { - String localPath = fs._local(delegate.path, relative: relative); + var localPath = fs._local(delegate.path, relative: relative); return _ChrootDirectory(fs, localPath); } @@ -32,7 +32,7 @@ class _ChrootDirectory extends _ChrootFileSystemEntity if (await fileSystem.type(path) != expectedType) { throw common.notADirectory(path); } - FileSystemEntityType type = await fileSystem.type(newPath); + var type = await fileSystem.type(newPath); if (type != FileSystemEntityType.notFound) { if (type != expectedType) { throw common.notADirectory(newPath); @@ -44,7 +44,7 @@ class _ChrootDirectory extends _ChrootFileSystemEntity throw common.directoryNotEmpty(newPath); } } - String target = await fileSystem.link(path).target(); + var target = await fileSystem.link(path).target(); await fileSystem.link(path).delete(); await fileSystem.link(newPath).create(target); return fileSystem.directory(newPath); @@ -60,7 +60,7 @@ class _ChrootDirectory extends _ChrootFileSystemEntity if (fileSystem.typeSync(path) != expectedType) { throw common.notADirectory(path); } - FileSystemEntityType type = fileSystem.typeSync(newPath); + var type = fileSystem.typeSync(newPath); if (type != FileSystemEntityType.notFound) { if (type != expectedType) { throw common.notADirectory(newPath); @@ -72,7 +72,7 @@ class _ChrootDirectory extends _ChrootFileSystemEntity throw common.directoryNotEmpty(newPath); } } - String target = fileSystem.link(path).targetSync(); + var target = fileSystem.link(path).targetSync(); fileSystem.link(path).deleteSync(); fileSystem.link(newPath).createSync(target); return fileSystem.directory(newPath); @@ -97,17 +97,15 @@ class _ChrootDirectory extends _ChrootFileSystemEntity @override Future create({bool recursive = false}) async { if (_isLink) { - switch (await fileSystem.type(path)) { - case FileSystemEntityType.notFound: - throw common.noSuchFileOrDirectory(path); - case FileSystemEntityType.file: - throw common.fileExists(path); - case FileSystemEntityType.directory: + return switch (await fileSystem.type(path)) { + FileSystemEntityType.notFound => + throw common.noSuchFileOrDirectory(path), + FileSystemEntityType.file => throw common.fileExists(path), + FileSystemEntityType.directory => // Nothing to do. - return this; - default: - throw AssertionError(); - } + this, + _ => throw AssertionError() + }; } else { return wrap(await delegate.create(recursive: recursive)); } @@ -137,8 +135,8 @@ class _ChrootDirectory extends _ChrootFileSystemEntity bool recursive = false, bool followLinks = true, }) { - Directory delegate = this.delegate as Directory; - String dirname = delegate.path; + var delegate = this.delegate as Directory; + var dirname = delegate.path; return delegate .list(recursive: recursive, followLinks: followLinks) .map((io.FileSystemEntity entity) => _denormalize(entity, dirname)); @@ -149,8 +147,8 @@ class _ChrootDirectory extends _ChrootFileSystemEntity bool recursive = false, bool followLinks = true, }) { - Directory delegate = this.delegate as Directory; - String dirname = delegate.path; + var delegate = this.delegate as Directory; + var dirname = delegate.path; return delegate .listSync(recursive: recursive, followLinks: followLinks) .map((io.FileSystemEntity entity) => _denormalize(entity, dirname)) @@ -158,9 +156,9 @@ class _ChrootDirectory extends _ChrootFileSystemEntity } FileSystemEntity _denormalize(io.FileSystemEntity entity, String dirname) { - p.Context ctx = fileSystem.path; - String relativePart = ctx.relative(entity.path, from: dirname); - String entityPath = ctx.join(path, relativePart); + var ctx = fileSystem.path; + var relativePart = ctx.relative(entity.path, from: dirname); + var entityPath = ctx.join(path, relativePart); if (entity is io.File) { return _ChrootFile(fileSystem, entityPath); } else if (entity is io.Directory) { diff --git a/pkgs/file/lib/src/backends/chroot/chroot_file.dart b/pkgs/file/lib/src/backends/chroot/chroot_file.dart index 4b67bc1f6..d6c29fcff 100644 --- a/pkgs/file/lib/src/backends/chroot/chroot_file.dart +++ b/pkgs/file/lib/src/backends/chroot/chroot_file.dart @@ -2,20 +2,20 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -part of file.src.backends.chroot; +part of '../chroot.dart'; typedef _SetupCallback = dynamic Function(); class _ChrootFile extends _ChrootFileSystemEntity with ForwardingFile { - _ChrootFile(ChrootFileSystem fs, String path) : super(fs, path); + _ChrootFile(super.fs, super.path); factory _ChrootFile.wrapped( ChrootFileSystem fs, io.File delegate, { bool relative = false, }) { - String localPath = fs._local(delegate.path, relative: relative); + var localPath = fs._local(delegate.path, relative: relative); return _ChrootFile(fs, localPath); } @@ -126,7 +126,7 @@ class _ChrootFile extends _ChrootFileSystemEntity @override Future create({bool recursive = false, bool exclusive = false}) async { - String path = fileSystem._resolve( + var path = fileSystem._resolve( this.path, followLinks: false, notFound: recursive ? _NotFoundBehavior.mkdir : _NotFoundBehavior.allow, @@ -158,7 +158,7 @@ class _ChrootFile extends _ChrootFileSystemEntity @override void createSync({bool recursive = false, bool exclusive = false}) { - String path = fileSystem._resolve( + var path = fileSystem._resolve( this.path, followLinks: false, notFound: recursive ? _NotFoundBehavior.mkdir : _NotFoundBehavior.allow, diff --git a/pkgs/file/lib/src/backends/chroot/chroot_file_system.dart b/pkgs/file/lib/src/backends/chroot/chroot_file_system.dart index 6889c987b..503821fb4 100644 --- a/pkgs/file/lib/src/backends/chroot/chroot_file_system.dart +++ b/pkgs/file/lib/src/backends/chroot/chroot_file_system.dart @@ -2,7 +2,7 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -part of file.src.backends.chroot; +part of '../chroot.dart'; const String _thisDir = '.'; const String _parentDir = '..'; @@ -107,7 +107,7 @@ class ChrootFileSystem extends FileSystem { } value = _resolve(value, notFound: _NotFoundBehavior.throwError); - String realPath = _real(value, resolve: false); + var realPath = _real(value, resolve: false); switch (delegate.typeSync(realPath, followLinks: false)) { case FileSystemEntityType.directory: break; @@ -117,7 +117,7 @@ class ChrootFileSystem extends FileSystem { throw common.notADirectory(path as String); } assert(() { - p.Context ctx = delegate.path; + var ctx = delegate.path; return ctx.isAbsolute(value) && value == ctx.canonicalize(value); }()); _cwd = value; @@ -201,7 +201,7 @@ class ChrootFileSystem extends FileSystem { throw _ChrootJailException(); } // TODO(tvolkert): See if _context.relative() works here - String result = realPath.substring(root.length); + var result = realPath.substring(root.length); if (result.isEmpty) { result = _localRoot; } @@ -263,8 +263,8 @@ class ChrootFileSystem extends FileSystem { throw common.noSuchFileOrDirectory(path); } - p.Context ctx = this.path; - String root = _localRoot; + var ctx = this.path; + var root = _localRoot; List parts, ledger; if (ctx.isAbsolute(path)) { parts = ctx.split(path).sublist(1); @@ -277,9 +277,9 @@ class ChrootFileSystem extends FileSystem { } String getCurrentPath() => root + ctx.joinAll(ledger); - Set breadcrumbs = {}; + var breadcrumbs = {}; while (parts.isNotEmpty) { - String segment = parts.removeAt(0); + var segment = parts.removeAt(0); if (segment == _thisDir) { continue; } else if (segment == _parentDir) { @@ -290,8 +290,8 @@ class ChrootFileSystem extends FileSystem { } ledger.add(segment); - String currentPath = getCurrentPath(); - String realPath = _real(currentPath, resolve: false); + var currentPath = getCurrentPath(); + var realPath = _real(currentPath, resolve: false); switch (delegate.typeSync(realPath, followLinks: false)) { case FileSystemEntityType.directory: @@ -333,7 +333,7 @@ class ChrootFileSystem extends FileSystem { if (!breadcrumbs.add(currentPath)) { throw common.tooManyLevelsOfSymbolicLinks(path); } - String target = delegate.link(realPath).targetSync(); + var target = delegate.link(realPath).targetSync(); if (ctx.isAbsolute(target)) { ledger.clear(); parts.insertAll(0, ctx.split(target).sublist(1)); diff --git a/pkgs/file/lib/src/backends/chroot/chroot_file_system_entity.dart b/pkgs/file/lib/src/backends/chroot/chroot_file_system_entity.dart index 8e859ace8..18e37cd02 100644 --- a/pkgs/file/lib/src/backends/chroot/chroot_file_system_entity.dart +++ b/pkgs/file/lib/src/backends/chroot/chroot_file_system_entity.dart @@ -2,7 +2,7 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -part of file.src.backends.chroot; +part of '../chroot.dart'; abstract class _ChrootFileSystemEntity extends ForwardingFileSystemEntity { @@ -103,7 +103,7 @@ abstract class _ChrootFileSystemEntity delete({bool recursive = false}) async { - String path = fileSystem._resolve(this.path, + var path = fileSystem._resolve(this.path, followLinks: false, notFound: _NotFoundBehavior.throwError); String real(String path) => fileSystem._real(path, resolve: false); @@ -114,7 +114,7 @@ abstract class _ChrootFileSystemEntity fileSystem._real(path, resolve: false); @@ -143,7 +143,7 @@ abstract class _ChrootFileSystemEntity with ForwardingLink { - _ChrootLink(ChrootFileSystem fs, String path) : super(fs, path); + _ChrootLink(super.fs, super.path); factory _ChrootLink.wrapped( ChrootFileSystem fs, io.Link delegate, { bool relative = false, }) { - String localPath = fs._local(delegate.path, relative: relative); + var localPath = fs._local(delegate.path, relative: relative); return _ChrootLink(fs, localPath); } diff --git a/pkgs/file/lib/src/backends/chroot/chroot_random_access_file.dart b/pkgs/file/lib/src/backends/chroot/chroot_random_access_file.dart index 4105ac807..10bbd7035 100644 --- a/pkgs/file/lib/src/backends/chroot/chroot_random_access_file.dart +++ b/pkgs/file/lib/src/backends/chroot/chroot_random_access_file.dart @@ -2,7 +2,7 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -part of file.src.backends.chroot; +part of '../chroot.dart'; class _ChrootRandomAccessFile with ForwardingRandomAccessFile { _ChrootRandomAccessFile(this.path, this.delegate); diff --git a/pkgs/file/lib/src/backends/local/local_directory.dart b/pkgs/file/lib/src/backends/local/local_directory.dart index e23e68fe0..3e1db6140 100644 --- a/pkgs/file/lib/src/backends/local/local_directory.dart +++ b/pkgs/file/lib/src/backends/local/local_directory.dart @@ -2,10 +2,10 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/file.dart'; -import 'package:file/src/common.dart' as common; -import 'package:file/src/io.dart' as io; - +import '../../common.dart' as common; +import '../../forwarding.dart'; +import '../../interface.dart'; +import '../../io.dart' as io; import 'local_file_system_entity.dart'; /// [Directory] implementation that forwards all calls to `dart:io`. @@ -13,7 +13,7 @@ class LocalDirectory extends LocalFileSystemEntity with ForwardingDirectory, common.DirectoryAddOnsMixin { /// Instantiates a new [LocalDirectory] tied to the specified file system /// and delegating to the specified [delegate]. - LocalDirectory(FileSystem fs, io.Directory delegate) : super(fs, delegate); + LocalDirectory(super.fs, super.delegate); @override String toString() => "LocalDirectory: '$path'"; diff --git a/pkgs/file/lib/src/backends/local/local_file.dart b/pkgs/file/lib/src/backends/local/local_file.dart index 36293ba51..a4bc10627 100644 --- a/pkgs/file/lib/src/backends/local/local_file.dart +++ b/pkgs/file/lib/src/backends/local/local_file.dart @@ -2,9 +2,9 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/file.dart'; -import 'package:file/src/io.dart' as io; - +import '../../forwarding.dart'; +import '../../interface.dart'; +import '../../io.dart' as io; import 'local_file_system_entity.dart'; /// [File] implementation that forwards all calls to `dart:io`. @@ -12,7 +12,7 @@ class LocalFile extends LocalFileSystemEntity with ForwardingFile { /// Instantiates a new [LocalFile] tied to the specified file system /// and delegating to the specified [delegate]. - LocalFile(FileSystem fs, io.File delegate) : super(fs, delegate); + LocalFile(super.fs, super.delegate); @override String toString() => "LocalFile: '$path'"; diff --git a/pkgs/file/lib/src/backends/local/local_file_system.dart b/pkgs/file/lib/src/backends/local/local_file_system.dart index 635998e10..7541c370f 100644 --- a/pkgs/file/lib/src/backends/local/local_file_system.dart +++ b/pkgs/file/lib/src/backends/local/local_file_system.dart @@ -2,10 +2,10 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/src/io.dart' as io; -import 'package:file/file.dart'; import 'package:path/path.dart' as p; +import '../../interface.dart'; +import '../../io.dart' as io; import 'local_directory.dart'; import 'local_file.dart'; import 'local_link.dart'; diff --git a/pkgs/file/lib/src/backends/local/local_file_system_entity.dart b/pkgs/file/lib/src/backends/local/local_file_system_entity.dart index ca4617b01..d0da55975 100644 --- a/pkgs/file/lib/src/backends/local/local_file_system_entity.dart +++ b/pkgs/file/lib/src/backends/local/local_file_system_entity.dart @@ -2,9 +2,9 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/file.dart'; -import 'package:file/src/io.dart' as io; - +import '../../forwarding.dart'; +import '../../interface.dart'; +import '../../io.dart' as io; import 'local_directory.dart'; import 'local_file.dart'; import 'local_link.dart'; diff --git a/pkgs/file/lib/src/backends/local/local_link.dart b/pkgs/file/lib/src/backends/local/local_link.dart index fc67d5e88..2ce479165 100644 --- a/pkgs/file/lib/src/backends/local/local_link.dart +++ b/pkgs/file/lib/src/backends/local/local_link.dart @@ -2,9 +2,9 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/file.dart'; -import 'package:file/src/io.dart' as io; - +import '../../forwarding.dart'; +import '../../interface.dart'; +import '../../io.dart' as io; import 'local_file_system_entity.dart'; /// [Link] implementation that forwards all calls to `dart:io`. @@ -12,7 +12,7 @@ class LocalLink extends LocalFileSystemEntity with ForwardingLink { /// Instantiates a new [LocalLink] tied to the specified file system /// and delegating to the specified [delegate]. - LocalLink(FileSystem fs, io.Link delegate) : super(fs, delegate); + LocalLink(super.fs, super.delegate); @override String toString() => "LocalLink: '$path'"; diff --git a/pkgs/file/lib/src/backends/memory/clock.dart b/pkgs/file/lib/src/backends/memory/clock.dart index 98d5434f9..57c1b72cd 100644 --- a/pkgs/file/lib/src/backends/memory/clock.dart +++ b/pkgs/file/lib/src/backends/memory/clock.dart @@ -2,6 +2,8 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +// ignore_for_file: comment_references + /// Interface describing clocks used by the [MemoryFileSystem]. /// /// The [MemoryFileSystem] uses a clock to determine the modification times of diff --git a/pkgs/file/lib/src/backends/memory/common.dart b/pkgs/file/lib/src/backends/memory/common.dart index 80e3c3851..eb4ca43d7 100644 --- a/pkgs/file/lib/src/backends/memory/common.dart +++ b/pkgs/file/lib/src/backends/memory/common.dart @@ -2,7 +2,7 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/src/common.dart' as common; +import '../../common.dart' as common; /// Generates a path to use in error messages. typedef PathGenerator = dynamic Function(); diff --git a/pkgs/file/lib/src/backends/memory/memory_directory.dart b/pkgs/file/lib/src/backends/memory/memory_directory.dart index 95fe54247..e73b96706 100644 --- a/pkgs/file/lib/src/backends/memory/memory_directory.dart +++ b/pkgs/file/lib/src/backends/memory/memory_directory.dart @@ -2,11 +2,11 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/file.dart'; -import 'package:file/src/common.dart' as common; -import 'package:file/src/io.dart' as io; import 'package:meta/meta.dart'; +import '../../common.dart' as common; +import '../../interface.dart'; +import '../../io.dart' as io; import 'common.dart'; import 'memory_file.dart'; import 'memory_file_system_entity.dart'; @@ -25,8 +25,7 @@ class MemoryDirectory extends MemoryFileSystemEntity with common.DirectoryAddOnsMixin implements Directory { /// Instantiates a new [MemoryDirectory]. - MemoryDirectory(NodeBasedFileSystem fileSystem, String path) - : super(fileSystem, path); + MemoryDirectory(super.fileSystem, super.path); @override io.FileSystemEntityType get expectedType => io.FileSystemEntityType.directory; @@ -52,7 +51,7 @@ class MemoryDirectory extends MemoryFileSystemEntity @override void createSync({bool recursive = false}) { fileSystem.opHandle(path, FileSystemOp.create); - Node? node = internalCreateSync( + var node = internalCreateSync( followTailLink: true, visitLinks: true, createChild: (DirectoryNode parent, bool isFinalSegment) { @@ -75,19 +74,19 @@ class MemoryDirectory extends MemoryFileSystemEntity @override Directory createTempSync([String? prefix]) { prefix = '${prefix ?? ''}rand'; - String fullPath = fileSystem.path.join(path, prefix); - String dirname = fileSystem.path.dirname(fullPath); - String basename = fileSystem.path.basename(fullPath); - DirectoryNode? node = fileSystem.findNode(dirname) as DirectoryNode?; + var fullPath = fileSystem.path.join(path, prefix); + var dirname = fileSystem.path.dirname(fullPath); + var basename = fileSystem.path.basename(fullPath); + var node = fileSystem.findNode(dirname) as DirectoryNode?; checkExists(node, () => dirname); utils.checkIsDir(node!, () => dirname); - int tempCounter = _systemTempCounter[fileSystem] ?? 0; + var tempCounter = _systemTempCounter[fileSystem] ?? 0; String name() => '$basename$tempCounter'; while (node.children.containsKey(name())) { tempCounter++; } _systemTempCounter[fileSystem] = tempCounter; - DirectoryNode tempDir = DirectoryNode(node); + var tempDir = DirectoryNode(node); node.children[name()] = tempDir; return MemoryDirectory(fileSystem, fileSystem.path.join(dirname, name())) ..createSync(); @@ -128,9 +127,9 @@ class MemoryDirectory extends MemoryFileSystemEntity bool recursive = false, bool followLinks = true, }) { - DirectoryNode node = backing as DirectoryNode; - List listing = []; - List<_PendingListTask> tasks = <_PendingListTask>[ + var node = backing as DirectoryNode; + var listing = []; + var tasks = <_PendingListTask>[ _PendingListTask( node, path.endsWith(fileSystem.path.separator) @@ -140,14 +139,14 @@ class MemoryDirectory extends MemoryFileSystemEntity ), ]; while (tasks.isNotEmpty) { - _PendingListTask task = tasks.removeLast(); + var task = tasks.removeLast(); task.dir.children.forEach((String name, Node child) { - Set breadcrumbs = Set.from(task.breadcrumbs); - String childPath = fileSystem.path.join(task.path, name); + var breadcrumbs = Set.from(task.breadcrumbs); + var childPath = fileSystem.path.join(task.path, name); while (followLinks && utils.isLink(child) && breadcrumbs.add(child as LinkNode)) { - Node? referent = child.referentOrNull; + var referent = child.referentOrNull; if (referent != null) { child = referent; } diff --git a/pkgs/file/lib/src/backends/memory/memory_file.dart b/pkgs/file/lib/src/backends/memory/memory_file.dart index ba4faab37..1a8f5f972 100644 --- a/pkgs/file/lib/src/backends/memory/memory_file.dart +++ b/pkgs/file/lib/src/backends/memory/memory_file.dart @@ -7,26 +7,25 @@ import 'dart:convert'; import 'dart:math' as math show min; import 'dart:typed_data'; -import 'package:file/file.dart'; -import 'package:file/src/backends/memory/operations.dart'; -import 'package:file/src/common.dart' as common; -import 'package:file/src/io.dart' as io; import 'package:meta/meta.dart'; +import '../../common.dart' as common; +import '../../interface.dart'; +import '../../io.dart' as io; import 'common.dart'; import 'memory_file_system_entity.dart'; import 'memory_random_access_file.dart'; import 'node.dart'; +import 'operations.dart'; import 'utils.dart' as utils; /// Internal implementation of [File]. class MemoryFile extends MemoryFileSystemEntity implements File { /// Instantiates a new [MemoryFile]. - const MemoryFile(NodeBasedFileSystem fileSystem, String path) - : super(fileSystem, path); + const MemoryFile(super.fileSystem, super.path); FileNode get _resolvedBackingOrCreate { - Node? node = backingOrNull; + var node = backingOrNull; if (node == null) { node = _doCreate(); } else { @@ -61,7 +60,7 @@ class MemoryFile extends MemoryFileSystemEntity implements File { } Node? _doCreate({bool recursive = false}) { - Node? node = internalCreateSync( + var node = internalCreateSync( followTailLink: true, createChild: (DirectoryNode parent, bool isFinalSegment) { if (isFinalSegment) { @@ -88,7 +87,7 @@ class MemoryFile extends MemoryFileSystemEntity implements File { newPath, followTailLink: true, checkType: (Node node) { - FileSystemEntityType actualType = node.stat.type; + var actualType = node.stat.type; if (actualType != expectedType) { throw actualType == FileSystemEntityType.notFound ? common.noSuchFileOrDirectory(path) @@ -103,7 +102,7 @@ class MemoryFile extends MemoryFileSystemEntity implements File { @override File copySync(String newPath) { fileSystem.opHandle(path, FileSystemOp.copy); - FileNode sourceNode = resolvedBacking as FileNode; + var sourceNode = resolvedBacking as FileNode; fileSystem.findNode( newPath, segmentVisitor: ( @@ -116,7 +115,7 @@ class MemoryFile extends MemoryFileSystemEntity implements File { if (currentSegment == finalSegment) { if (child != null) { if (utils.isLink(child)) { - List ledger = []; + var ledger = []; child = utils.resolveLinks(child as LinkNode, () => newPath, ledger: ledger); checkExists(child, () => newPath); @@ -127,7 +126,7 @@ class MemoryFile extends MemoryFileSystemEntity implements File { utils.checkType(expectedType, child.type, () => newPath); parent.children.remove(childName); } - FileNode newNode = FileNode(parent); + var newNode = FileNode(parent); newNode.copyFrom(sourceNode); parent.children[childName] = newNode; } @@ -158,7 +157,7 @@ class MemoryFile extends MemoryFileSystemEntity implements File { @override void setLastAccessedSync(DateTime time) { - FileNode node = resolvedBacking as FileNode; + var node = resolvedBacking as FileNode; node.accessed = time.millisecondsSinceEpoch; } @@ -174,7 +173,7 @@ class MemoryFile extends MemoryFileSystemEntity implements File { @override void setLastModifiedSync(DateTime time) { - FileNode node = resolvedBacking as FileNode; + var node = resolvedBacking as FileNode; node.modified = time.millisecondsSinceEpoch; } @@ -199,8 +198,8 @@ class MemoryFile extends MemoryFileSystemEntity implements File { Stream> openRead([int? start, int? end]) { fileSystem.opHandle(path, FileSystemOp.open); try { - FileNode node = resolvedBacking as FileNode; - Uint8List content = node.content; + var node = resolvedBacking as FileNode; + var content = node.content; if (start != null) { content = end == null ? content.sublist(start) @@ -253,13 +252,13 @@ class MemoryFile extends MemoryFileSystemEntity implements File { @override List readAsLinesSync({Encoding encoding = utf8}) { - String str = readAsStringSync(encoding: encoding); + var str = readAsStringSync(encoding: encoding); if (str.isEmpty) { return []; } - final List lines = str.split('\n'); + final lines = str.split('\n'); if (str.endsWith('\n')) { // A final newline should not create an additional line. lines.removeLast(); @@ -287,7 +286,7 @@ class MemoryFile extends MemoryFileSystemEntity implements File { if (!utils.isWriteMode(mode)) { throw common.badFileDescriptor(path); } - FileNode node = _resolvedBackingOrCreate; + var node = _resolvedBackingOrCreate; _truncateIfNecessary(node, mode); fileSystem.opHandle(path, FileSystemOp.write); node.write(bytes); @@ -349,7 +348,7 @@ class _FileSink implements io.IOSink { deferredException = e; } - Future future = Future.microtask(() { + var future = Future.microtask(() { if (deferredException != null) { throw deferredException; } @@ -387,7 +386,7 @@ class _FileSink implements io.IOSink { @override void writeAll(Iterable objects, [String separator = '']) { - bool firstIter = true; + var firstIter = true; for (dynamic obj in objects) { if (!firstIter) { write(separator); @@ -418,7 +417,7 @@ class _FileSink implements io.IOSink { _streamCompleter = Completer(); stream.listen( - (List data) => _addData(data), + _addData, cancelOnError: true, onError: (Object error, StackTrace stackTrace) { _streamCompleter!.completeError(error, stackTrace); @@ -445,8 +444,7 @@ class _FileSink implements io.IOSink { _isClosed = true; _pendingWrites.then( (_) => _completer.complete(), - onError: (Object error, StackTrace stackTrace) => - _completer.completeError(error, stackTrace), + onError: _completer.completeError, ); } return _completer.future; diff --git a/pkgs/file/lib/src/backends/memory/memory_file_stat.dart b/pkgs/file/lib/src/backends/memory/memory_file_stat.dart index 94f86d155..ce6bedac1 100644 --- a/pkgs/file/lib/src/backends/memory/memory_file_stat.dart +++ b/pkgs/file/lib/src/backends/memory/memory_file_stat.dart @@ -2,7 +2,7 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/src/io.dart' as io; +import '../../io.dart' as io; /// Internal implementation of [io.FileStat]. class MemoryFileStat implements io.FileStat { @@ -47,8 +47,8 @@ class MemoryFileStat implements io.FileStat { @override String modeString() { - int permissions = mode & 0xFFF; - List codes = const [ + var permissions = mode & 0xFFF; + var codes = const [ '---', '--x', '-w-', @@ -58,7 +58,7 @@ class MemoryFileStat implements io.FileStat { 'rw-', 'rwx', ]; - List result = []; + var result = []; result ..add(codes[(permissions >> 6) & 0x7]) ..add(codes[(permissions >> 3) & 0x7]) diff --git a/pkgs/file/lib/src/backends/memory/memory_file_system.dart b/pkgs/file/lib/src/backends/memory/memory_file_system.dart index f3cdaeede..dd359f049 100644 --- a/pkgs/file/lib/src/backends/memory/memory_file_system.dart +++ b/pkgs/file/lib/src/backends/memory/memory_file_system.dart @@ -2,11 +2,10 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/file.dart'; -import 'package:file/src/backends/memory/operations.dart'; -import 'package:file/src/io.dart' as io; import 'package:path/path.dart' as p; +import '../../interface.dart'; +import '../../io.dart' as io; import 'clock.dart'; import 'common.dart'; import 'memory_directory.dart'; @@ -14,6 +13,7 @@ import 'memory_file.dart'; import 'memory_file_stat.dart'; import 'memory_link.dart'; import 'node.dart'; +import 'operations.dart'; import 'style.dart'; import 'utils.dart' as utils; @@ -91,7 +91,7 @@ class _MemoryFileSystem extends FileSystem p.Context _context; @override - final Function(String context, FileSystemOp operation) opHandle; + final void Function(String context, FileSystemOp operation) opHandle; @override final Clock clock; @@ -141,7 +141,7 @@ class _MemoryFileSystem extends FileSystem } value = directory(value).resolveSymbolicLinksSync(); - Node? node = findNode(value); + var node = findNode(value); checkExists(node, () => value); utils.checkIsDir(node!, () => value); assert(_context.isAbsolute(value)); @@ -166,9 +166,9 @@ class _MemoryFileSystem extends FileSystem @override bool identicalSync(String path1, String path2) { - Node? node1 = findNode(path1); + var node1 = findNode(path1); checkExists(node1, () => path1); - Node? node2 = findNode(path2); + var node2 = findNode(path2); checkExists(node2, () => path2); return node1 != null && node1 == node2; } @@ -220,14 +220,13 @@ class _MemoryFileSystem extends FileSystem reference ??= _current; } - List parts = path.split(style.separator) - ..removeWhere(utils.isEmpty); - DirectoryNode? directory = reference?.directory; + var parts = path.split(style.separator)..removeWhere(utils.isEmpty); + var directory = reference?.directory; Node? child = directory; - int finalSegment = parts.length - 1; - for (int i = 0; i <= finalSegment; i++) { - String basename = parts[i]; + var finalSegment = parts.length - 1; + for (var i = 0; i <= finalSegment; i++) { + var basename = parts[i]; assert(basename.isNotEmpty); switch (basename) { diff --git a/pkgs/file/lib/src/backends/memory/memory_file_system_entity.dart b/pkgs/file/lib/src/backends/memory/memory_file_system_entity.dart index ad987d71a..1990abcd0 100644 --- a/pkgs/file/lib/src/backends/memory/memory_file_system_entity.dart +++ b/pkgs/file/lib/src/backends/memory/memory_file_system_entity.dart @@ -2,11 +2,11 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/file.dart'; -import 'package:file/src/common.dart' as common; -import 'package:file/src/io.dart' as io; import 'package:meta/meta.dart'; +import '../../common.dart' as common; +import '../../interface.dart'; +import '../../io.dart' as io; import 'common.dart'; import 'memory_directory.dart'; import 'node.dart'; @@ -60,7 +60,7 @@ abstract class MemoryFileSystemEntity implements FileSystemEntity { /// The type of the node is not guaranteed to match [expectedType]. @protected Node get backing { - Node? node = fileSystem.findNode(path); + var node = fileSystem.findNode(path); checkExists(node, () => path); return node!; } @@ -71,7 +71,7 @@ abstract class MemoryFileSystemEntity implements FileSystemEntity { /// doesn't match, this will throw a [io.FileSystemException]. @protected Node get resolvedBacking { - Node node = backing; + var node = backing; node = utils.isLink(node) ? utils.resolveLinks(node as LinkNode, () => path) : node; @@ -107,14 +107,14 @@ abstract class MemoryFileSystemEntity implements FileSystemEntity { if (path.isEmpty) { throw common.noSuchFileOrDirectory(path); } - List ledger = []; + var ledger = []; if (isAbsolute) { ledger.add(fileSystem.style.drive); } - Node? node = fileSystem.findNode(path, + var node = fileSystem.findNode(path, pathWithSymlinks: ledger, followTailLink: true); checkExists(node, () => path); - String resolved = ledger.join(fileSystem.path.separator); + var resolved = ledger.join(fileSystem.path.separator); if (resolved == fileSystem.style.drive) { resolved = fileSystem.style.root; } else if (!fileSystem.path.isAbsolute(resolved)) { @@ -151,7 +151,7 @@ abstract class MemoryFileSystemEntity implements FileSystemEntity { @override FileSystemEntity get absolute { - String absolutePath = path; + var absolutePath = path; if (!fileSystem.path.isAbsolute(absolutePath)) { absolutePath = fileSystem.path.join(fileSystem.cwd, absolutePath); } @@ -242,7 +242,7 @@ abstract class MemoryFileSystemEntity implements FileSystemEntity { bool followTailLink = false, utils.TypeChecker? checkType, }) { - Node node = backing; + var node = backing; (checkType ?? defaultCheckType)(node); fileSystem.findNode( newPath, @@ -256,7 +256,7 @@ abstract class MemoryFileSystemEntity implements FileSystemEntity { if (currentSegment == finalSegment) { if (child != null) { if (followTailLink) { - FileSystemEntityType childType = child.stat.type; + var childType = child.stat.type; if (childType != FileSystemEntityType.notFound) { utils.checkType(expectedType, child.stat.type, () => newPath); } @@ -289,7 +289,7 @@ abstract class MemoryFileSystemEntity implements FileSystemEntity { utils.TypeChecker? checkType, }) { fileSystem.opHandle(path, FileSystemOp.delete); - Node node = backing; + var node = backing; if (!recursive) { if (node is DirectoryNode && node.children.isNotEmpty) { throw common.directoryNotEmpty(path); diff --git a/pkgs/file/lib/src/backends/memory/memory_link.dart b/pkgs/file/lib/src/backends/memory/memory_link.dart index 7d5afb42f..a599fe8f1 100644 --- a/pkgs/file/lib/src/backends/memory/memory_link.dart +++ b/pkgs/file/lib/src/backends/memory/memory_link.dart @@ -2,11 +2,11 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/file.dart'; -import 'package:file/src/common.dart' as common; -import 'package:file/src/io.dart' as io; import 'package:meta/meta.dart'; +import '../../common.dart' as common; +import '../../interface.dart'; +import '../../io.dart' as io; import 'memory_file_system_entity.dart'; import 'node.dart'; import 'operations.dart'; @@ -15,8 +15,7 @@ import 'utils.dart' as utils; /// Internal implementation of [Link]. class MemoryLink extends MemoryFileSystemEntity implements Link { /// Instantiates a new [MemoryLink]. - const MemoryLink(NodeBasedFileSystem fileSystem, String path) - : super(fileSystem, path); + const MemoryLink(super.fileSystem, super.path); @override io.FileSystemEntityType get expectedType => io.FileSystemEntityType.link; @@ -50,7 +49,7 @@ class MemoryLink extends MemoryFileSystemEntity implements Link { @override void createSync(String target, {bool recursive = false}) { - bool preexisting = true; + var preexisting = true; fileSystem.opHandle(path, FileSystemOp.create); internalCreateSync( createChild: (DirectoryNode parent, bool isFinalSegment) { @@ -76,7 +75,7 @@ class MemoryLink extends MemoryFileSystemEntity implements Link { @override void updateSync(String target) { - Node node = backing; + var node = backing; utils.checkType(expectedType, node.type, () => path); (node as LinkNode).target = target; } @@ -93,7 +92,7 @@ class MemoryLink extends MemoryFileSystemEntity implements Link { @override String targetSync() { - Node node = backing; + var node = backing; if (node.type != expectedType) { // Note: this may change; https://github.com/dart-lang/sdk/issues/28204 throw common.noSuchFileOrDirectory(path); diff --git a/pkgs/file/lib/src/backends/memory/memory_random_access_file.dart b/pkgs/file/lib/src/backends/memory/memory_random_access_file.dart index d4fe73d18..190f0a137 100644 --- a/pkgs/file/lib/src/backends/memory/memory_random_access_file.dart +++ b/pkgs/file/lib/src/backends/memory/memory_random_access_file.dart @@ -6,10 +6,11 @@ import 'dart:convert'; import 'dart:math' as math show min; import 'dart:typed_data'; -import 'package:file/src/common.dart' as common; -import 'package:file/src/io.dart' as io; - +import '../../common.dart' as common; +import '../../io.dart' as io; +import '../memory.dart' show MemoryFileSystem; import 'memory_file.dart'; +import 'memory_file_system.dart' show MemoryFileSystem; import 'node.dart'; import 'utils.dart' as utils; @@ -106,8 +107,8 @@ class MemoryRandomAccessFile implements io.RandomAccessFile { /// Wraps a synchronous function to make it appear asynchronous. /// /// [_asyncOperationPending], [_checkAsync], and [_asyncWrapper] are used to - /// mimic [RandomAccessFile]'s enforcement that only one asynchronous - /// operation is pending for a [RandomAccessFile] instance. Since + /// mimic [io.RandomAccessFile]'s enforcement that only one asynchronous + /// operation is pending for a [io.RandomAccessFile] instance. Since /// [MemoryFileSystem]-based classes are likely to be used in tests, fidelity /// is important to catch errors that might occur in production. /// @@ -211,7 +212,7 @@ class MemoryRandomAccessFile implements io.RandomAccessFile { _checkReadable('read'); // TODO(jamesderlin): Check for integer overflow. final int end = math.min(_position + bytes, lengthSync()); - final Uint8List copy = _node.content.sublist(_position, end); + final copy = _node.content.sublist(_position, end); _position = end; return copy; } @@ -243,7 +244,7 @@ class MemoryRandomAccessFile implements io.RandomAccessFile { end = RangeError.checkValidRange(start, end, buffer.length); - final int length = lengthSync(); + final length = lengthSync(); int i; for (i = start; i < end && _position < length; i += 1, _position += 1) { buffer[i] = _node.content[_position]; @@ -288,7 +289,7 @@ class MemoryRandomAccessFile implements io.RandomAccessFile { 'truncate failed', path, common.invalidArgument(path).osError); } - final int oldLength = lengthSync(); + final oldLength = lengthSync(); if (length < oldLength) { _node.truncate(length); @@ -329,7 +330,7 @@ class MemoryRandomAccessFile implements io.RandomAccessFile { // [Uint8List] will truncate values to 8-bits automatically, so we don't // need to check [value]. - int length = lengthSync(); + var length = lengthSync(); if (_position >= length) { // If [_position] is out of bounds, [RandomAccessFile] zero-fills the // file. @@ -363,8 +364,8 @@ class MemoryRandomAccessFile implements io.RandomAccessFile { end = RangeError.checkValidRange(start, end, buffer.length); - final int writeByteCount = end - start; - final int endPosition = _position + writeByteCount; + final writeByteCount = end - start; + final endPosition = _position + writeByteCount; if (endPosition > lengthSync()) { truncateSync(endPosition); diff --git a/pkgs/file/lib/src/backends/memory/node.dart b/pkgs/file/lib/src/backends/memory/node.dart index ae4d3f75d..eea72b58c 100644 --- a/pkgs/file/lib/src/backends/memory/node.dart +++ b/pkgs/file/lib/src/backends/memory/node.dart @@ -4,13 +4,12 @@ import 'dart:typed_data'; -import 'package:file/file.dart'; -import 'package:file/src/backends/memory/operations.dart'; -import 'package:file/src/io.dart' as io; - +import '../../interface.dart'; +import '../../io.dart' as io; import 'clock.dart'; import 'common.dart'; import 'memory_file_stat.dart'; +import 'operations.dart'; import 'style.dart'; /// Visitor callback for use with [NodeBasedFileSystem.findNode]. @@ -115,7 +114,7 @@ abstract class Node { /// Reparents this node to live in the specified directory. set parent(DirectoryNode parent) { - DirectoryNode ancestor = parent; + var ancestor = parent; while (!ancestor.isRoot) { if (ancestor == this) { throw const io.FileSystemException( @@ -149,8 +148,8 @@ abstract class Node { /// you call [stat] on them). abstract class RealNode extends Node { /// Constructs a new [RealNode] as a child of the specified [parent]. - RealNode(DirectoryNode? parent) : super(parent) { - int now = clock.now.millisecondsSinceEpoch; + RealNode(super.parent) { + var now = clock.now.millisecondsSinceEpoch; changed = now; modified = now; accessed = now; @@ -195,7 +194,7 @@ abstract class RealNode extends Node { /// Class that represents the backing for an in-memory directory. class DirectoryNode extends RealNode { /// Constructs a new [DirectoryNode] as a child of the specified [parent]. - DirectoryNode(DirectoryNode? parent) : super(parent); + DirectoryNode(super.parent); /// Child nodes, indexed by their basename. final Map children = {}; @@ -237,7 +236,7 @@ class RootNode extends DirectoryNode { /// Class that represents the backing for an in-memory regular file. class FileNode extends RealNode { /// Constructs a new [FileNode] as a child of the specified [parent]. - FileNode(DirectoryNode parent) : super(parent); + FileNode(DirectoryNode super.parent); /// File contents in bytes. Uint8List get content => _content; @@ -251,7 +250,7 @@ class FileNode extends RealNode { /// Appends the specified bytes to the end of this node's [content]. void write(List bytes) { - Uint8List existing = _content; + var existing = _content; _content = Uint8List(existing.length + bytes.length); _content.setRange(0, existing.length, existing); _content.setRange(existing.length, _content.length, bytes); @@ -286,9 +285,7 @@ class FileNode extends RealNode { class LinkNode extends Node { /// Constructs a new [LinkNode] as a child of the specified [parent] and /// linking to the specified [target] path. - LinkNode(DirectoryNode parent, this.target) - : assert(target.isNotEmpty), - super(parent); + LinkNode(DirectoryNode super.parent, this.target) : assert(target.isNotEmpty); /// The path to which this link points. String target; @@ -309,7 +306,7 @@ class LinkNode extends Node { Node? Function(DirectoryNode parent, String childName, Node? child)? tailVisitor, }) { - Node? referent = fs.findNode( + var referent = fs.findNode( target, reference: this, segmentVisitor: ( @@ -349,7 +346,7 @@ class LinkNode extends Node { } _reentrant = true; try { - Node? node = referentOrNull; + var node = referentOrNull; return node == null ? MemoryFileStat.notFound : node.stat; } finally { _reentrant = false; diff --git a/pkgs/file/lib/src/backends/memory/operations.dart b/pkgs/file/lib/src/backends/memory/operations.dart index 9fc7462fc..57d118b11 100644 --- a/pkgs/file/lib/src/backends/memory/operations.dart +++ b/pkgs/file/lib/src/backends/memory/operations.dart @@ -2,6 +2,8 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +// ignore_for_file: comment_references + /// A file system operation used by the [MemoryFileSytem] to allow /// tests to insert errors for certain operations. /// @@ -64,23 +66,15 @@ class FileSystemOp { @override String toString() { - switch (_value) { - case 0: - return 'FileSystemOp.read'; - case 1: - return 'FileSystemOp.write'; - case 2: - return 'FileSystemOp.delete'; - case 3: - return 'FileSystemOp.create'; - case 4: - return 'FileSystemOp.open'; - case 5: - return 'FileSystemOp.copy'; - case 6: - return 'FileSystemOp.exists'; - default: - throw StateError('Invalid FileSytemOp type: $this'); - } + return switch (_value) { + 0 => 'FileSystemOp.read', + 1 => 'FileSystemOp.write', + 2 => 'FileSystemOp.delete', + 3 => 'FileSystemOp.create', + 4 => 'FileSystemOp.open', + 5 => 'FileSystemOp.copy', + 6 => 'FileSystemOp.exists', + _ => throw StateError('Invalid FileSytemOp type: $this') + }; } } diff --git a/pkgs/file/lib/src/backends/memory/style.dart b/pkgs/file/lib/src/backends/memory/style.dart index 701c9d05e..f4bd33fb7 100644 --- a/pkgs/file/lib/src/backends/memory/style.dart +++ b/pkgs/file/lib/src/backends/memory/style.dart @@ -2,9 +2,10 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/file.dart'; import 'package:path/path.dart' as p; +import '../../interface.dart'; + /// Class that represents the path style that a memory file system should /// adopt. /// diff --git a/pkgs/file/lib/src/backends/memory/utils.dart b/pkgs/file/lib/src/backends/memory/utils.dart index eec998038..aa24cfb57 100644 --- a/pkgs/file/lib/src/backends/memory/utils.dart +++ b/pkgs/file/lib/src/backends/memory/utils.dart @@ -2,20 +2,19 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/file.dart'; -import 'package:file/src/common.dart' as common; -import 'package:file/src/io.dart' as io; - +import '../../common.dart' as common; +import '../../interface.dart'; +import '../../io.dart' as io; import 'common.dart'; import 'node.dart'; -/// Checks if `node.type` returns [io.FileSystemEntityType.FILE]. +/// Checks if `node.type` returns [io.FileSystemEntityType.file]. bool isFile(Node? node) => node?.type == io.FileSystemEntityType.file; -/// Checks if `node.type` returns [io.FileSystemEntityType.DIRECTORY]. +/// Checks if `node.type` returns [io.FileSystemEntityType.directory]. bool isDirectory(Node? node) => node?.type == io.FileSystemEntityType.directory; -/// Checks if `node.type` returns [io.FileSystemEntityType.LINK]. +/// Checks if `node.type` returns [io.FileSystemEntityType.link]. bool isLink(Node? node) => node?.type == io.FileSystemEntityType.link; /// Validator function that is expected to throw a [FileSystemException] if @@ -86,7 +85,7 @@ Node resolveLinks( tailVisitor, }) { // Record a breadcrumb trail to guard against symlink loops. - Set breadcrumbs = {}; + var breadcrumbs = {}; Node node = link; while (isLink(node)) { diff --git a/pkgs/file/lib/src/forwarding/forwarding_directory.dart b/pkgs/file/lib/src/forwarding/forwarding_directory.dart index dba0c8ed6..ad1c548c1 100644 --- a/pkgs/file/lib/src/forwarding/forwarding_directory.dart +++ b/pkgs/file/lib/src/forwarding/forwarding_directory.dart @@ -2,8 +2,9 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/src/io.dart' as io; -import 'package:file/file.dart'; +import '../forwarding.dart'; +import '../interface.dart'; +import '../io.dart' as io; /// A directory that forwards all methods and properties to a delegate. mixin ForwardingDirectory diff --git a/pkgs/file/lib/src/forwarding/forwarding_file.dart b/pkgs/file/lib/src/forwarding/forwarding_file.dart index 49c211db7..d6cfe3bb7 100644 --- a/pkgs/file/lib/src/forwarding/forwarding_file.dart +++ b/pkgs/file/lib/src/forwarding/forwarding_file.dart @@ -5,8 +5,9 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:file/src/io.dart' as io; -import 'package:file/file.dart'; +import '../forwarding.dart'; +import '../interface.dart'; +import '../io.dart' as io; /// A file that forwards all methods and properties to a delegate. mixin ForwardingFile diff --git a/pkgs/file/lib/src/forwarding/forwarding_file_system.dart b/pkgs/file/lib/src/forwarding/forwarding_file_system.dart index d864db94c..885fdb618 100644 --- a/pkgs/file/lib/src/forwarding/forwarding_file_system.dart +++ b/pkgs/file/lib/src/forwarding/forwarding_file_system.dart @@ -2,11 +2,12 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/src/io.dart' as io; -import 'package:file/file.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; +import '../interface.dart'; +import '../io.dart' as io; + /// A file system that forwards all methods and properties to a delegate. abstract class ForwardingFileSystem extends FileSystem { /// Creates a new [ForwardingFileSystem] that forwards all methods and diff --git a/pkgs/file/lib/src/forwarding/forwarding_file_system_entity.dart b/pkgs/file/lib/src/forwarding/forwarding_file_system_entity.dart index 3c41b39b4..1c0628ee4 100644 --- a/pkgs/file/lib/src/forwarding/forwarding_file_system_entity.dart +++ b/pkgs/file/lib/src/forwarding/forwarding_file_system_entity.dart @@ -2,10 +2,11 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/src/io.dart' as io; -import 'package:file/file.dart'; import 'package:meta/meta.dart'; +import '../interface.dart'; +import '../io.dart' as io; + /// A file system entity that forwards all methods and properties to a delegate. abstract class ForwardingFileSystemEntity implements FileSystemEntity { diff --git a/pkgs/file/lib/src/forwarding/forwarding_link.dart b/pkgs/file/lib/src/forwarding/forwarding_link.dart index 7a60ecbfe..915e710cf 100644 --- a/pkgs/file/lib/src/forwarding/forwarding_link.dart +++ b/pkgs/file/lib/src/forwarding/forwarding_link.dart @@ -2,8 +2,9 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/src/io.dart' as io; -import 'package:file/file.dart'; +import '../forwarding.dart'; +import '../interface.dart'; +import '../io.dart' as io; /// A link that forwards all methods and properties to a delegate. mixin ForwardingLink diff --git a/pkgs/file/lib/src/forwarding/forwarding_random_access_file.dart b/pkgs/file/lib/src/forwarding/forwarding_random_access_file.dart index 9dd407930..3847b91f4 100644 --- a/pkgs/file/lib/src/forwarding/forwarding_random_access_file.dart +++ b/pkgs/file/lib/src/forwarding/forwarding_random_access_file.dart @@ -5,11 +5,12 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:file/src/io.dart' as io; import 'package:meta/meta.dart'; -/// A [RandomAccessFile] implementation that forwards all methods and properties -/// to a delegate. +import '../io.dart' as io; + +/// A [io.RandomAccessFile] implementation that forwards all methods and +/// properties to a delegate. mixin ForwardingRandomAccessFile implements io.RandomAccessFile { /// The entity to which this entity will forward all methods and properties. @protected diff --git a/pkgs/file/lib/src/interface.dart b/pkgs/file/lib/src/interface.dart index 4662e3515..d9b7ed592 100644 --- a/pkgs/file/lib/src/interface.dart +++ b/pkgs/file/lib/src/interface.dart @@ -2,8 +2,6 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -library file.src.interface; - export 'interface/directory.dart'; export 'interface/error_codes.dart'; export 'interface/file.dart'; diff --git a/pkgs/file/lib/src/interface/error_codes.dart b/pkgs/file/lib/src/interface/error_codes.dart index 8943538cb..4836b5658 100644 --- a/pkgs/file/lib/src/interface/error_codes.dart +++ b/pkgs/file/lib/src/interface/error_codes.dart @@ -168,7 +168,7 @@ class ErrorCodes { static int get EXDEV => _platform((_Codes codes) => codes.exdev); static int _platform(int Function(_Codes codes) getCode) { - _Codes codes = (_platforms[operatingSystem] ?? _platforms['linux'])!; + var codes = (_platforms[operatingSystem] ?? _platforms['linux'])!; return getCode(codes); } } diff --git a/pkgs/file/lib/src/interface/file_system.dart b/pkgs/file/lib/src/interface/file_system.dart index ecc01a801..2d4e4aa3b 100644 --- a/pkgs/file/lib/src/interface/file_system.dart +++ b/pkgs/file/lib/src/interface/file_system.dart @@ -6,7 +6,6 @@ import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import '../io.dart' as io; - import 'directory.dart'; import 'file.dart'; import 'file_system_entity.dart'; @@ -99,9 +98,9 @@ abstract class FileSystem { bool get isWatchSupported; /// Finds the type of file system object that a [path] points to. Returns - /// a Future that completes with the result. + /// a `Future` that completes with the result. /// - /// [io.FileSystemEntityType.LINK] will only be returned if [followLinks] is + /// [io.FileSystemEntityType.link] will only be returned if [followLinks] is /// `false`, and [path] points to a link /// /// If the [path] does not point to a file system object or an error occurs @@ -111,37 +110,38 @@ abstract class FileSystem { /// Syncronously finds the type of file system object that a [path] points /// to. Returns a [io.FileSystemEntityType]. /// - /// [io.FileSystemEntityType.LINK] will only be returned if [followLinks] is + /// [io.FileSystemEntityType.link] will only be returned if [followLinks] is /// `false`, and [path] points to a link /// /// If the [path] does not point to a file system object or an error occurs /// then [io.FileSystemEntityType.notFound] is returned. io.FileSystemEntityType typeSync(String path, {bool followLinks = true}); - /// Checks if [`type(path)`](type) returns [io.FileSystemEntityType.FILE]. + /// Checks if [`type(path)`](type) returns [io.FileSystemEntityType.file]. Future isFile(String path) async => await type(path) == io.FileSystemEntityType.file; /// Synchronously checks if [`type(path)`](type) returns - /// [io.FileSystemEntityType.FILE]. + /// [io.FileSystemEntityType.file]. bool isFileSync(String path) => typeSync(path) == io.FileSystemEntityType.file; - /// Checks if [`type(path)`](type) returns [io.FileSystemEntityType.DIRECTORY]. + /// Checks if [`type(path)`](type) returns + /// [io.FileSystemEntityType.directory]. Future isDirectory(String path) async => await type(path) == io.FileSystemEntityType.directory; /// Synchronously checks if [`type(path)`](type) returns - /// [io.FileSystemEntityType.DIRECTORY]. + /// [io.FileSystemEntityType.directory]. bool isDirectorySync(String path) => typeSync(path) == io.FileSystemEntityType.directory; - /// Checks if [`type(path)`](type) returns [io.FileSystemEntityType.LINK]. + /// Checks if [`type(path)`](type) returns [io.FileSystemEntityType.link]. Future isLink(String path) async => await type(path, followLinks: false) == io.FileSystemEntityType.link; /// Synchronously checks if [`type(path)`](type) returns - /// [io.FileSystemEntityType.LINK]. + /// [io.FileSystemEntityType.link]. bool isLinkSync(String path) => typeSync(path, followLinks: false) == io.FileSystemEntityType.link; diff --git a/pkgs/file/lib/src/io.dart b/pkgs/file/lib/src/io.dart index 9d57e7869..28c1d6dfa 100644 --- a/pkgs/file/lib/src/io.dart +++ b/pkgs/file/lib/src/io.dart @@ -8,6 +8,8 @@ /// the `file` package. The `file` package re-exports these interfaces (or in /// some cases, implementations of these interfaces by the same name), so this /// file need not be exposes publicly and exists for internal use only. +library; + export 'dart:io' show Directory, diff --git a/pkgs/file/pubspec.yaml b/pkgs/file/pubspec.yaml index 5de5d37c1..0ad65b0d6 100644 --- a/pkgs/file/pubspec.yaml +++ b/pkgs/file/pubspec.yaml @@ -1,5 +1,5 @@ name: file -version: 7.0.1 +version: 7.0.2-wip description: A pluggable, mockable file system abstraction for Dart. repository: https://github.com/dart-lang/tools/tree/main/pkgs/file issue_tracker: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Afile @@ -12,6 +12,10 @@ dependencies: path: ^1.8.3 dev_dependencies: + dart_flutter_team_lints: ^3.0.0 file_testing: ^3.0.0 - lints: ^2.0.1 test: ^1.23.1 + +dependency_overrides: + file_testing: + path: ../file_testing diff --git a/pkgs/file/test/chroot_test.dart b/pkgs/file/test/chroot_test.dart index 6c34ff200..cf23f4755 100644 --- a/pkgs/file/test/chroot_test.dart +++ b/pkgs/file/test/chroot_test.dart @@ -3,6 +3,8 @@ // BSD-style license that can be found in the LICENSE file. @TestOn('vm') +library; + import 'dart:io' as io; import 'package:file/chroot.dart'; @@ -17,14 +19,15 @@ import 'common_tests.dart'; void main() { group('ChrootFileSystem', () { ChrootFileSystem createMemoryBackedChrootFileSystem() { - MemoryFileSystem fs = MemoryFileSystem(); + var fs = MemoryFileSystem(); fs.directory('/tmp').createSync(); return ChrootFileSystem(fs, '/tmp'); } // TODO(jamesderlin): Make ChrootFile.openSync return a delegating // RandomAccessFile that uses the chroot'd path. - List skipCommon = [ + var skipCommon = [ + // ignore: lines_longer_than_80_chars 'File > open > .* > RandomAccessFile > read > openReadHandleDoesNotChange', 'File > open > .* > RandomAccessFile > openWriteHandleDoesNotChange', ]; @@ -137,6 +140,7 @@ void main() { test('referencesRootEntityForJailbreakPath', () { mem.file('/foo').createSync(); dynamic f = fs.file('../foo'); + // ignore: avoid_dynamic_calls expect(f.delegate.path, '/tmp/foo'); }); }); @@ -151,7 +155,7 @@ void main() { group('copy', () { test('copiesToRootDirectoryIfDestinationIsJailbreakPath', () { - File f = fs.file('/foo')..createSync(); + var f = fs.file('/foo')..createSync(); f.copySync('../bar'); expect(mem.file('/bar'), isNot(exists)); expect(mem.file('/tmp/bar'), exists); diff --git a/pkgs/file/test/common_tests.dart b/pkgs/file/test/common_tests.dart index 6028c7715..491d4f985 100644 --- a/pkgs/file/test/common_tests.dart +++ b/pkgs/file/test/common_tests.dart @@ -3,6 +3,8 @@ // BSD-style license that can be found in the LICENSE file. @TestOn('vm') +library; + import 'dart:async'; import 'dart:convert'; import 'dart:io' as io; @@ -10,8 +12,8 @@ import 'dart:io' as io; import 'package:file/file.dart'; import 'package:file_testing/file_testing.dart'; import 'package:path/path.dart' as p; -import 'package:test/test.dart'; import 'package:test/test.dart' as testpkg show group, setUp, tearDown, test; +import 'package:test/test.dart'; import 'utils.dart'; @@ -54,7 +56,7 @@ void runCommonTests( List skip = const [], FileSystemGenerator? replay, }) { - RootPathGenerator? rootfn = root; + var rootfn = root; group('common', () { late FileSystemGenerator createFs; @@ -62,7 +64,7 @@ void runCommonTests( late List tearDowns; late FileSystem fs; late String root; - List stack = []; + var stack = []; void skipIfNecessary(String description, void Function() callback) { stack.add(description); @@ -105,7 +107,7 @@ void runCommonTests( testpkg.setUp(() async { await Future.forEach(setUps, (SetUpTearDown setUp) => setUp()); await body(); - for (SetUpTearDown tearDown in tearDowns) { + for (var tearDown in tearDowns) { await tearDown(); } createFs = replay; @@ -115,7 +117,7 @@ void runCommonTests( testpkg.test(description, body, skip: skip); testpkg.tearDown(() async { - for (SetUpTearDown tearDown in tearDowns) { + for (var tearDown in tearDowns) { await tearDown(); } }); @@ -126,13 +128,13 @@ void runCommonTests( /// Returns [path] prefixed by the [root] namespace. /// This is only intended for absolute paths. String ns(String path) { - p.Context posix = p.Context(style: p.Style.posix); - List parts = posix.split(path); + var posix = p.Context(style: p.Style.posix); + var parts = posix.split(path); parts[0] = root; path = fs.path.joinAll(parts); - String rootPrefix = fs.path.rootPrefix(path); + var rootPrefix = fs.path.rootPrefix(path); assert(rootPrefix.isNotEmpty); - String result = root == rootPrefix + var result = root == rootPrefix ? path : (path == rootPrefix ? root @@ -160,7 +162,7 @@ void runCommonTests( test('succeedsWithUriArgument', () { fs.directory(ns('/foo')).createSync(); - Uri uri = fs.path.toUri(ns('/foo')); + var uri = fs.path.toUri(ns('/foo')); expect(fs.directory(uri), exists); }); @@ -173,11 +175,11 @@ void runCommonTests( }); // Fails due to - // https://github.com/google/file.dart/issues/112 + // https://github.com/dart-lang/tools/issues/632 test('considersBothSlashesEquivalent', () { fs.directory(r'foo\bar_dir').createSync(recursive: true); expect(fs.directory(r'foo/bar_dir'), exists); - }, skip: 'Fails due to https://github.com/google/file.dart/issues/112'); + }, skip: 'Fails due to https://github.com/dart-lang/tools/issues/632'); }); group('file', () { @@ -191,7 +193,7 @@ void runCommonTests( test('succeedsWithUriArgument', () { fs.file(ns('/foo')).createSync(); - Uri uri = fs.path.toUri(ns('/foo')); + var uri = fs.path.toUri(ns('/foo')); expect(fs.file(uri), exists); }); @@ -204,11 +206,11 @@ void runCommonTests( }); // Fails due to - // https://github.com/google/file.dart/issues/112 + // https://github.com/dart-lang/tools/issues/632 test('considersBothSlashesEquivalent', () { fs.file(r'foo\bar_file').createSync(recursive: true); expect(fs.file(r'foo/bar_file'), exists); - }, skip: 'Fails due to https://github.com/google/file.dart/issues/112'); + }, skip: 'Fails due to https://github.com/dart-lang/tools/issues/632'); }); group('link', () { @@ -223,7 +225,7 @@ void runCommonTests( test('succeedsWithUriArgument', () { fs.file(ns('/foo')).createSync(); fs.link(ns('/bar')).createSync(ns('/foo')); - Uri uri = fs.path.toUri(ns('/bar')); + var uri = fs.path.toUri(ns('/bar')); expect(fs.link(uri), exists); }); @@ -248,7 +250,7 @@ void runCommonTests( group('systemTempDirectory', () { test('existsAsDirectory', () { - Directory tmp = fs.systemTempDirectory; + var tmp = fs.systemTempDirectory; expect(tmp, isDirectory); expect(tmp, exists); }); @@ -318,7 +320,7 @@ void runCommonTests( test('staysAtRootIfSetToParentOfRoot', () { fs.currentDirectory = List.filled(20, '..').join(fs.path.separator); - String cwd = fs.currentDirectory.path; + var cwd = fs.currentDirectory.path; expect(cwd, fs.path.rootPrefix(cwd)); }); @@ -371,36 +373,36 @@ void runCommonTests( group('stat', () { test('isNotFoundForEmptyPath', () { - FileStat stat = fs.statSync(''); + var stat = fs.statSync(''); expect(stat.type, FileSystemEntityType.notFound); }); test('isNotFoundForPathToNonExistentEntityAtTail', () { - FileStat stat = fs.statSync(ns('/foo')); + var stat = fs.statSync(ns('/foo')); expect(stat.type, FileSystemEntityType.notFound); }); test('isNotFoundForPathToNonExistentEntityInTraversal', () { - FileStat stat = fs.statSync(ns('/foo/bar')); + var stat = fs.statSync(ns('/foo/bar')); expect(stat.type, FileSystemEntityType.notFound); }); test('isDirectoryForDirectory', () { fs.directory(ns('/foo')).createSync(); - FileStat stat = fs.statSync(ns('/foo')); + var stat = fs.statSync(ns('/foo')); expect(stat.type, FileSystemEntityType.directory); }); test('isFileForFile', () { fs.file(ns('/foo')).createSync(); - FileStat stat = fs.statSync(ns('/foo')); + var stat = fs.statSync(ns('/foo')); expect(stat.type, FileSystemEntityType.file); }); test('isFileForLinkToFile', () { fs.file(ns('/foo')).createSync(); fs.link(ns('/bar')).createSync(ns('/foo')); - FileStat stat = fs.statSync(ns('/bar')); + var stat = fs.statSync(ns('/bar')); expect(stat.type, FileSystemEntityType.file); }); @@ -408,7 +410,7 @@ void runCommonTests( fs.link(ns('/foo')).createSync(ns('/bar')); fs.link(ns('/bar')).createSync(ns('/baz')); fs.link(ns('/baz')).createSync(ns('/foo')); - FileStat stat = fs.statSync(ns('/foo')); + var stat = fs.statSync(ns('/foo')); expect(stat.type, FileSystemEntityType.notFound); }); }); @@ -454,18 +456,18 @@ void runCommonTests( group('type', () { test('isFileForFile', () { fs.file(ns('/foo')).createSync(); - FileSystemEntityType type = fs.typeSync(ns('/foo')); + var type = fs.typeSync(ns('/foo')); expect(type, FileSystemEntityType.file); }); test('isDirectoryForDirectory', () { fs.directory(ns('/foo')).createSync(); - FileSystemEntityType type = fs.typeSync(ns('/foo')); + var type = fs.typeSync(ns('/foo')); expect(type, FileSystemEntityType.directory); }); test('isDirectoryForAncestorOfRoot', () { - FileSystemEntityType type = fs + var type = fs .typeSync(List.filled(20, '..').join(fs.path.separator)); expect(type, FileSystemEntityType.directory); }); @@ -473,15 +475,14 @@ void runCommonTests( test('isFileForLinkToFileAndFollowLinksTrue', () { fs.file(ns('/foo')).createSync(); fs.link(ns('/bar')).createSync(ns('/foo')); - FileSystemEntityType type = fs.typeSync(ns('/bar')); + var type = fs.typeSync(ns('/bar')); expect(type, FileSystemEntityType.file); }); test('isLinkForLinkToFileAndFollowLinksFalse', () { fs.file(ns('/foo')).createSync(); fs.link(ns('/bar')).createSync(ns('/foo')); - FileSystemEntityType type = - fs.typeSync(ns('/bar'), followLinks: false); + var type = fs.typeSync(ns('/bar'), followLinks: false); expect(type, FileSystemEntityType.link); }); @@ -489,17 +490,17 @@ void runCommonTests( fs.link(ns('/foo')).createSync(ns('/bar')); fs.link(ns('/bar')).createSync(ns('/baz')); fs.link(ns('/baz')).createSync(ns('/foo')); - FileSystemEntityType type = fs.typeSync(ns('/foo')); + var type = fs.typeSync(ns('/foo')); expect(type, FileSystemEntityType.notFound); }); test('isNotFoundForNoEntityAtTail', () { - FileSystemEntityType type = fs.typeSync(ns('/foo')); + var type = fs.typeSync(ns('/foo')); expect(type, FileSystemEntityType.notFound); }); test('isNotFoundForNoDirectoryInTraversal', () { - FileSystemEntityType type = fs.typeSync(ns('/foo/bar/baz')); + var type = fs.typeSync(ns('/foo/bar/baz')); expect(type, FileSystemEntityType.notFound); }); }); @@ -676,8 +677,8 @@ void runCommonTests( }); test('succeedsIfDestinationDoesntExist', () { - Directory src = fs.directory(ns('/foo'))..createSync(); - Directory dest = src.renameSync(ns('/bar')); + var src = fs.directory(ns('/foo'))..createSync(); + var dest = src.renameSync(ns('/bar')); expect(dest.path, ns('/bar')); expect(dest, exists); }); @@ -686,8 +687,8 @@ void runCommonTests( 'succeedsIfDestinationIsEmptyDirectory', () { fs.directory(ns('/bar')).createSync(); - Directory src = fs.directory(ns('/foo'))..createSync(); - Directory dest = src.renameSync(ns('/bar')); + var src = fs.directory(ns('/foo'))..createSync(); + var dest = src.renameSync(ns('/bar')); expect(src, isNot(exists)); expect(dest, exists); }, @@ -697,14 +698,14 @@ void runCommonTests( test('throwsIfDestinationIsFile', () { fs.file(ns('/bar')).createSync(); - Directory src = fs.directory(ns('/foo'))..createSync(); + var src = fs.directory(ns('/foo'))..createSync(); expectFileSystemException(ErrorCodes.ENOTDIR, () { src.renameSync(ns('/bar')); }); }); test('throwsIfDestinationParentFolderDoesntExist', () { - Directory src = fs.directory(ns('/foo'))..createSync(); + var src = fs.directory(ns('/foo'))..createSync(); expectFileSystemException(ErrorCodes.ENOENT, () { src.renameSync(ns('/bar/baz')); }); @@ -712,7 +713,7 @@ void runCommonTests( test('throwsIfDestinationIsNonEmptyDirectory', () { fs.file(ns('/bar/baz')).createSync(recursive: true); - Directory src = fs.directory(ns('/foo'))..createSync(); + var src = fs.directory(ns('/foo'))..createSync(); // The error will be 'Directory not empty' on OS X, but it will be // 'File exists' on Linux. expectFileSystemException( @@ -749,7 +750,7 @@ void runCommonTests( }); test('throwsIfDestinationIsLinkToNotFound', () { - Directory src = fs.directory(ns('/foo'))..createSync(); + var src = fs.directory(ns('/foo'))..createSync(); fs.link(ns('/bar')).createSync(ns('/baz')); expectFileSystemException(ErrorCodes.ENOTDIR, () { src.renameSync(ns('/bar')); @@ -757,7 +758,7 @@ void runCommonTests( }); test('throwsIfDestinationIsLinkToEmptyDirectory', () { - Directory src = fs.directory(ns('/foo'))..createSync(); + var src = fs.directory(ns('/foo'))..createSync(); fs.directory(ns('/bar')).createSync(); fs.link(ns('/baz')).createSync(ns('/bar')); expectFileSystemException(ErrorCodes.ENOTDIR, () { @@ -766,7 +767,7 @@ void runCommonTests( }); test('succeedsIfDestinationIsInDifferentDirectory', () { - Directory src = fs.directory(ns('/foo'))..createSync(); + var src = fs.directory(ns('/foo'))..createSync(); fs.directory(ns('/bar')).createSync(); src.renameSync(ns('/bar/baz')); expect(fs.typeSync(ns('/foo')), FileSystemEntityType.notFound); @@ -790,24 +791,24 @@ void runCommonTests( group('delete', () { test('returnsCovariantType', () async { - Directory dir = fs.directory(ns('/foo'))..createSync(); + var dir = fs.directory(ns('/foo'))..createSync(); expect(await dir.delete(), isDirectory); }); test('succeedsIfEmptyDirectoryExistsAndRecursiveFalse', () { - Directory dir = fs.directory(ns('/foo'))..createSync(); + var dir = fs.directory(ns('/foo'))..createSync(); dir.deleteSync(); expect(dir, isNot(exists)); }); test('succeedsIfEmptyDirectoryExistsAndRecursiveTrue', () { - Directory dir = fs.directory(ns('/foo'))..createSync(); + var dir = fs.directory(ns('/foo'))..createSync(); dir.deleteSync(recursive: true); expect(dir, isNot(exists)); }); test('throwsIfNonEmptyDirectoryExistsAndRecursiveFalse', () { - Directory dir = fs.directory(ns('/foo'))..createSync(); + var dir = fs.directory(ns('/foo'))..createSync(); fs.file(ns('/foo/bar')).createSync(); expectFileSystemException(ErrorCodes.ENOTEMPTY, () { dir.deleteSync(); @@ -815,7 +816,7 @@ void runCommonTests( }); test('succeedsIfNonEmptyDirectoryExistsAndRecursiveTrue', () { - Directory dir = fs.directory(ns('/foo'))..createSync(); + var dir = fs.directory(ns('/foo'))..createSync(); fs.file(ns('/foo/bar')).createSync(); dir.deleteSync(recursive: true); expect(fs.directory(ns('/foo')), isNot(exists)); @@ -997,7 +998,7 @@ void runCommonTests( test('handlesParentAndThisFolderReferences', () { fs.directory(ns('/foo/bar/baz')).createSync(recursive: true); fs.link(ns('/foo/bar/baz/qux')).createSync(fs.path.join('..', '..')); - String resolved = fs + var resolved = fs .directory(ns('/foo/./bar/baz/../baz/qux/bar')) .resolveSymbolicLinksSync(); expect(resolved, ns('/foo/bar')); @@ -1015,7 +1016,7 @@ void runCommonTests( .createSync(fs.path.join('..', '..', 'qux'), recursive: true); fs.link(ns('/qux')).createSync('quux'); fs.link(ns('/quux/quuz')).createSync(ns('/foo'), recursive: true); - String resolved = fs + var resolved = fs .directory(ns('/foo//bar/./baz/quuz/bar/..///bar/baz/')) .resolveSymbolicLinksSync(); expect(resolved, ns('/quux')); @@ -1069,29 +1070,29 @@ void runCommonTests( test('resolvesNameCollisions', () { fs.directory(ns('/foo/bar')).createSync(recursive: true); - Directory tmp = fs.directory(ns('/foo')).createTempSync('bar'); + var tmp = fs.directory(ns('/foo')).createTempSync('bar'); expect(tmp.path, allOf(isNot(ns('/foo/bar')), startsWith(ns('/foo/bar')))); }); test('succeedsWithoutPrefix', () { - Directory dir = fs.directory(ns('/foo'))..createSync(); + var dir = fs.directory(ns('/foo'))..createSync(); expect(dir.createTempSync().path, startsWith(ns('/foo/'))); }); test('succeedsWithPrefix', () { - Directory dir = fs.directory(ns('/foo'))..createSync(); + var dir = fs.directory(ns('/foo'))..createSync(); expect(dir.createTempSync('bar').path, startsWith(ns('/foo/bar'))); }); test('succeedsWithNestedPathPrefixThatExists', () { fs.directory(ns('/foo/bar')).createSync(recursive: true); - Directory tmp = fs.directory(ns('/foo')).createTempSync('bar/baz'); + var tmp = fs.directory(ns('/foo')).createTempSync('bar/baz'); expect(tmp.path, startsWith(ns('/foo/bar/baz'))); }); test('throwsWithNestedPathPrefixThatDoesntExist', () { - Directory dir = fs.directory(ns('/foo'))..createSync(); + var dir = fs.directory(ns('/foo'))..createSync(); expectFileSystemException(ErrorCodes.ENOENT, () { dir.createTempSync('bar/baz'); }); @@ -1123,7 +1124,7 @@ void runCommonTests( }); test('returnsEmptyListForEmptyDirectory', () { - Directory empty = fs.directory(ns('/bar'))..createSync(); + var empty = fs.directory(ns('/bar'))..createSync(); expect(empty.listSync(), isEmpty); }); @@ -1134,7 +1135,7 @@ void runCommonTests( }); test('returnsLinkObjectsIfFollowLinksFalse', () { - List list = dir.listSync(followLinks: false); + var list = dir.listSync(followLinks: false); expect(list, hasLength(3)); expect(list, contains(allOf(isFile, hasPath(ns('/foo/bar'))))); expect(list, contains(allOf(isDirectory, hasPath(ns('/foo/baz'))))); @@ -1142,7 +1143,7 @@ void runCommonTests( }); test('followsLinksIfFollowLinksTrue', () { - List list = dir.listSync(); + var list = dir.listSync(); expect(list, hasLength(3)); expect(list, contains(allOf(isFile, hasPath(ns('/foo/bar'))))); expect(list, contains(allOf(isDirectory, hasPath(ns('/foo/baz'))))); @@ -1189,8 +1190,7 @@ void runCommonTests( test('childEntriesNotNormalized', () { dir = fs.directory(ns('/bar/baz'))..createSync(recursive: true); fs.file(ns('/bar/baz/qux')).createSync(); - List list = - fs.directory(ns('/bar//../bar/./baz')).listSync(); + var list = fs.directory(ns('/bar//../bar/./baz')).listSync(); expect(list, hasLength(1)); expect(list[0], allOf(isFile, hasPath(ns('/bar//../bar/./baz/qux')))); }); @@ -1198,9 +1198,8 @@ void runCommonTests( test('symlinksToNotFoundAlwaysReturnedAsLinks', () { dir = fs.directory(ns('/bar'))..createSync(); fs.link(ns('/bar/baz')).createSync('qux'); - for (bool followLinks in const [true, false]) { - List list = - dir.listSync(followLinks: followLinks); + for (var followLinks in const [true, false]) { + var list = dir.listSync(followLinks: followLinks); expect(list, hasLength(1)); expect(list[0], allOf(isLink, hasPath(ns('/bar/baz')))); } @@ -1208,7 +1207,7 @@ void runCommonTests( }); test('childEntities', () { - Directory dir = fs.directory(ns('/foo'))..createSync(); + var dir = fs.directory(ns('/foo'))..createSync(); dir.childDirectory('bar').createSync(); dir.childFile('baz').createSync(); dir.childLink('qux').createSync('bar'); @@ -1321,22 +1320,22 @@ void runCommonTests( }); test('succeedsIfDestinationDoesntExistAtTail', () { - File src = fs.file(ns('/foo'))..createSync(); - File dest = src.renameSync(ns('/bar')); + var src = fs.file(ns('/foo'))..createSync(); + var dest = src.renameSync(ns('/bar')); expect(fs.file(ns('/foo')), isNot(exists)); expect(fs.file(ns('/bar')), exists); expect(dest.path, ns('/bar')); }); test('throwsIfDestinationDoesntExistViaTraversal', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); expectFileSystemException(ErrorCodes.ENOENT, () { f.renameSync(ns('/bar/baz')); }); }); test('succeedsIfDestinationExistsAsFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); fs.file(ns('/bar')).createSync(); f.renameSync(ns('/bar')); expect(fs.file(ns('/foo')), isNot(exists)); @@ -1344,7 +1343,7 @@ void runCommonTests( }); test('throwsIfDestinationExistsAsDirectory', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); fs.directory(ns('/bar')).createSync(); expectFileSystemException(ErrorCodes.EISDIR, () { f.renameSync(ns('/bar')); @@ -1352,7 +1351,7 @@ void runCommonTests( }); test('succeedsIfDestinationExistsAsLinkToFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); fs.file(ns('/bar')).createSync(); fs.link(ns('/baz')).createSync(ns('/bar')); f.renameSync(ns('/baz')); @@ -1364,7 +1363,7 @@ void runCommonTests( }); test('throwsIfDestinationExistsAsLinkToDirectory', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); fs.directory(ns('/bar')).createSync(); fs.link(ns('/baz')).createSync(ns('/bar')); expectFileSystemException(ErrorCodes.EISDIR, () { @@ -1373,7 +1372,7 @@ void runCommonTests( }); test('succeedsIfDestinationExistsAsLinkToNotFound', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); fs.link(ns('/bar')).createSync(ns('/baz')); f.renameSync(ns('/bar')); expect(fs.typeSync(ns('/foo')), FileSystemEntityType.notFound); @@ -1429,7 +1428,7 @@ void runCommonTests( }); test('succeedsIfDestinationDoesntExistAtTail', () { - File f = fs.file(ns('/foo')) + var f = fs.file(ns('/foo')) ..createSync() ..writeAsStringSync('foo'); f.copySync(ns('/bar')); @@ -1439,14 +1438,14 @@ void runCommonTests( }); test('throwsIfDestinationDoesntExistViaTraversal', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); expectFileSystemException(ErrorCodes.ENOENT, () { f.copySync(ns('/bar/baz')); }); }); test('succeedsIfDestinationExistsAsFile', () { - File f = fs.file(ns('/foo')) + var f = fs.file(ns('/foo')) ..createSync() ..writeAsStringSync('foo'); fs.file(ns('/bar')) @@ -1460,7 +1459,7 @@ void runCommonTests( }); test('throwsIfDestinationExistsAsDirectory', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); fs.directory(ns('/bar')).createSync(); expectFileSystemException(ErrorCodes.EISDIR, () { f.copySync(ns('/bar')); @@ -1468,7 +1467,7 @@ void runCommonTests( }); test('succeedsIfDestinationExistsAsLinkToFile', () { - File f = fs.file(ns('/foo')) + var f = fs.file(ns('/foo')) ..createSync() ..writeAsStringSync('foo'); fs.file(ns('/bar')) @@ -1487,7 +1486,7 @@ void runCommonTests( }, skip: io.Platform.isWindows /* No links on Windows */); test('throwsIfDestinationExistsAsLinkToDirectory', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); fs.directory(ns('/bar')).createSync(); fs.link(ns('/baz')).createSync(ns('/bar')); expectFileSystemException(ErrorCodes.EISDIR, () { @@ -1525,7 +1524,7 @@ void runCommonTests( }); test('succeedsIfDestinationIsInDifferentDirectoryThanSource', () { - File f = fs.file(ns('/foo/bar')) + var f = fs.file(ns('/foo/bar')) ..createSync(recursive: true) ..writeAsStringSync('foo'); fs.directory(ns('/baz')).createSync(); @@ -1587,12 +1586,12 @@ void runCommonTests( }); test('returnsZeroForNewlyCreatedFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); expect(f.lengthSync(), 0); }); test('writeNBytesReturnsLengthN', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsBytesSync([1, 2, 3, 4], flush: true); expect(f.lengthSync(), 4); }); @@ -1616,10 +1615,10 @@ void runCommonTests( group('lastAccessed', () { test('isNowForNewlyCreatedFile', () { - DateTime before = downstairs(); - File f = fs.file(ns('/foo'))..createSync(); - DateTime after = ceil(); - DateTime accessed = f.lastAccessedSync(); + var before = downstairs(); + var f = fs.file(ns('/foo'))..createSync(); + var after = ceil(); + var accessed = f.lastAccessedSync(); expect(accessed, isSameOrAfter(before)); expect(accessed, isSameOrBefore(after)); }); @@ -1638,18 +1637,18 @@ void runCommonTests( }); test('succeedsIfExistsAsLinkToFile', () { - DateTime before = downstairs(); + var before = downstairs(); fs.file(ns('/foo')).createSync(); fs.link(ns('/bar')).createSync(ns('/foo')); - DateTime after = ceil(); - DateTime accessed = fs.file(ns('/bar')).lastAccessedSync(); + var after = ceil(); + var accessed = fs.file(ns('/bar')).lastAccessedSync(); expect(accessed, isSameOrAfter(before)); expect(accessed, isSameOrBefore(after)); }); }); group('setLastAccessed', () { - final DateTime time = DateTime(1999); + final time = DateTime(1999); test('throwsIfDoesntExist', () { expectFileSystemException(ErrorCodes.ENOENT, () { @@ -1665,13 +1664,13 @@ void runCommonTests( }); test('succeedsIfExistsAsFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.setLastAccessedSync(time); expect(fs.file(ns('/foo')).lastAccessedSync(), time); }); test('succeedsIfExistsAsLinkToFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); fs.link(ns('/bar')).createSync(ns('/foo')); f.setLastAccessedSync(time); expect(fs.file(ns('/bar')).lastAccessedSync(), time); @@ -1680,10 +1679,10 @@ void runCommonTests( group('lastModified', () { test('isNowForNewlyCreatedFile', () { - DateTime before = downstairs(); - File f = fs.file(ns('/foo'))..createSync(); - DateTime after = ceil(); - DateTime modified = f.lastModifiedSync(); + var before = downstairs(); + var f = fs.file(ns('/foo'))..createSync(); + var after = ceil(); + var modified = f.lastModifiedSync(); expect(modified, isSameOrAfter(before)); expect(modified, isSameOrBefore(after)); }); @@ -1702,18 +1701,18 @@ void runCommonTests( }); test('succeedsIfExistsAsLinkToFile', () { - DateTime before = downstairs(); + var before = downstairs(); fs.file(ns('/foo')).createSync(); fs.link(ns('/bar')).createSync(ns('/foo')); - DateTime after = ceil(); - DateTime modified = fs.file(ns('/bar')).lastModifiedSync(); + var after = ceil(); + var modified = fs.file(ns('/bar')).lastModifiedSync(); expect(modified, isSameOrAfter(before)); expect(modified, isSameOrBefore(after)); }); }); group('setLastModified', () { - final DateTime time = DateTime(1999); + final time = DateTime(1999); test('throwsIfDoesntExist', () { expectFileSystemException(ErrorCodes.ENOENT, () { @@ -1729,13 +1728,13 @@ void runCommonTests( }); test('succeedsIfExistsAsFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.setLastModifiedSync(time); expect(fs.file(ns('/foo')).lastModifiedSync(), time); }); test('succeedsIfExistsAsLinkToFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); fs.link(ns('/bar')).createSync(ns('/foo')); f.setLastModifiedSync(time); expect(fs.file(ns('/bar')).lastModifiedSync(), time); @@ -1752,7 +1751,7 @@ void runCommonTests( }); } else { test('createsFileIfDoesntExistAtTail', () { - RandomAccessFile raf = fs.file(ns('/bar')).openSync(mode: mode); + var raf = fs.file(ns('/bar')).openSync(mode: mode); raf.closeSync(); expect(fs.file(ns('/bar')), exists); }); @@ -1877,39 +1876,39 @@ void runCommonTests( }); test('readIntoWithBufferLargerThanContent', () { - List buffer = List.filled(1024, 0); - int numRead = raf.readIntoSync(buffer); + var buffer = List.filled(1024, 0); + var numRead = raf.readIntoSync(buffer); expect(numRead, 21); expect(utf8.decode(buffer.sublist(0, 21)), 'pre-existing content\n'); }); test('readIntoWithBufferSmallerThanContent', () { - List buffer = List.filled(10, 0); - int numRead = raf.readIntoSync(buffer); + var buffer = List.filled(10, 0); + var numRead = raf.readIntoSync(buffer); expect(numRead, 10); expect(utf8.decode(buffer), 'pre-existi'); }); test('readIntoWithStart', () { - List buffer = List.filled(10, 0); - int numRead = raf.readIntoSync(buffer, 2); + var buffer = List.filled(10, 0); + var numRead = raf.readIntoSync(buffer, 2); expect(numRead, 8); expect(utf8.decode(buffer.sublist(2)), 'pre-exis'); }); test('readIntoWithStartAndEnd', () { - List buffer = List.filled(10, 0); - int numRead = raf.readIntoSync(buffer, 2, 5); + var buffer = List.filled(10, 0); + var numRead = raf.readIntoSync(buffer, 2, 5); expect(numRead, 3); expect(utf8.decode(buffer.sublist(2, 5)), 'pre'); }); test('openReadHandleDoesNotChange', () { - final String initial = utf8.decode(raf.readSync(4)); + final initial = utf8.decode(raf.readSync(4)); expect(initial, 'pre-'); - final File newFile = f.renameSync(ns('/bar')); - String rest = utf8.decode(raf.readSync(1024)); + final newFile = f.renameSync(ns('/bar')); + var rest = utf8.decode(raf.readSync(1024)); expect(rest, 'existing content\n'); assert(newFile.path != f.path); @@ -1942,13 +1941,13 @@ void runCommonTests( }); } else { test('lengthGrowsAsDataIsWritten', () { - int lengthBefore = f.lengthSync(); + var lengthBefore = f.lengthSync(); raf.writeByteSync(0xFACE); expect(raf.lengthSync(), lengthBefore + 1); }); test('flush', () { - int lengthBefore = f.lengthSync(); + var lengthBefore = f.lengthSync(); raf.writeByteSync(0xFACE); raf.flushSync(); expect(f.lengthSync(), lengthBefore + 1); @@ -2009,10 +2008,10 @@ void runCommonTests( test('openWriteHandleDoesNotChange', () { raf.writeStringSync('Hello '); - final File newFile = f.renameSync(ns('/bar')); + final newFile = f.renameSync(ns('/bar')); raf.writeStringSync('world'); - final String contents = newFile.readAsStringSync(); + final contents = newFile.readAsStringSync(); if (mode == FileMode.write || mode == FileMode.writeOnly) { expect(contents, 'Hello world'); } else { @@ -2067,7 +2066,7 @@ void runCommonTests( }); } else { test('growsAfterWrite', () { - int positionBefore = raf.positionSync(); + var positionBefore = raf.positionSync(); raf.writeStringSync('Hello world'); expect(raf.positionSync(), positionBefore + 11); }); @@ -2165,42 +2164,42 @@ void runCommonTests( group('openRead', () { test('throwsIfDoesntExist', () { - Stream> stream = fs.file(ns('/foo')).openRead(); + var stream = fs.file(ns('/foo')).openRead(); expect(stream.drain(), throwsFileSystemException(ErrorCodes.ENOENT)); }); test('succeedsIfExistsAsFile', () async { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsStringSync('Hello world', flush: true); - Stream> stream = f.openRead(); - List> data = await stream.toList(); + var stream = f.openRead(); + var data = await stream.toList(); expect(data, hasLength(1)); expect(utf8.decode(data[0]), 'Hello world'); }); test('throwsIfExistsAsDirectory', () { fs.directory(ns('/foo')).createSync(); - Stream> stream = fs.file(ns('/foo')).openRead(); + var stream = fs.file(ns('/foo')).openRead(); expect(stream.drain(), throwsFileSystemException(ErrorCodes.EISDIR)); }); test('succeedsIfExistsAsLinkToFile', () async { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); fs.link(ns('/bar')).createSync(ns('/foo')); f.writeAsStringSync('Hello world', flush: true); - Stream> stream = fs.file(ns('/bar')).openRead(); - List> data = await stream.toList(); + var stream = fs.file(ns('/bar')).openRead(); + var data = await stream.toList(); expect(data, hasLength(1)); expect(utf8.decode(data[0]), 'Hello world'); }); test('respectsStartAndEndParameters', () async { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsStringSync('Hello world', flush: true); - Stream> stream = f.openRead(2); - List> data = await stream.toList(); + var stream = f.openRead(2); + var data = await stream.toList(); expect(data, hasLength(1)); expect(utf8.decode(data[0]), 'llo world'); stream = f.openRead(2, 5); @@ -2210,24 +2209,24 @@ void runCommonTests( }); test('throwsIfStartParameterIsNegative', () async { - File f = fs.file(ns('/foo'))..createSync(); - Stream> stream = f.openRead(-2); + var f = fs.file(ns('/foo'))..createSync(); + var stream = f.openRead(-2); expect(stream.drain(), throwsRangeError); }); test('stopsAtEndOfFileIfEndParameterIsPastEndOfFile', () async { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsStringSync('Hello world', flush: true); - Stream> stream = f.openRead(2, 1024); - List> data = await stream.toList(); + var stream = f.openRead(2, 1024); + var data = await stream.toList(); expect(data, hasLength(1)); expect(utf8.decode(data[0]), 'llo world'); }); test('providesSingleSubscriptionStream', () async { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsStringSync('Hello world', flush: true); - Stream> stream = f.openRead(); + var stream = f.openRead(); expect(stream.isBroadcast, isFalse); await stream.drain(); }); @@ -2237,20 +2236,20 @@ void runCommonTests( // split across multiple chunks in the [Stream]. However, there // doesn't seem to be a good way to determine the chunk size used by // [io.File]. - final List data = List.generate( + final data = List.generate( 1024 * 256, (int index) => index & 0xFF, growable: false, ); - final File f = fs.file(ns('/foo'))..createSync(); + final f = fs.file(ns('/foo'))..createSync(); f.writeAsBytesSync(data, flush: true); - final Stream> stream = f.openRead(); + final stream = f.openRead(); File? newFile; List? initialChunk; - final List remainingChunks = []; + final remainingChunks = []; await for (List chunk in stream) { if (initialChunk == null) { @@ -2276,7 +2275,7 @@ void runCommonTests( test('openReadCompatibleWithUtf8Decoder', () async { const content = 'Hello world!'; - File file = fs.file(ns('/foo')) + var file = fs.file(ns('/foo')) ..createSync() ..writeAsStringSync(content); expect( @@ -2315,8 +2314,8 @@ void runCommonTests( }); test('succeedsIfExistsAsEmptyFile', () async { - File f = fs.file(ns('/foo'))..createSync(); - IOSink sink = f.openWrite(); + var f = fs.file(ns('/foo'))..createSync(); + var sink = f.openWrite(); sink.write('Hello world'); await sink.flush(); await sink.close(); @@ -2326,7 +2325,7 @@ void runCommonTests( test('succeedsIfExistsAsLinkToFile', () async { fs.file(ns('/foo')).createSync(); fs.link(ns('/bar')).createSync(ns('/foo')); - IOSink sink = fs.file(ns('/bar')).openWrite(); + var sink = fs.file(ns('/bar')).openWrite(); sink.write('Hello world'); await sink.flush(); await sink.close(); @@ -2334,9 +2333,9 @@ void runCommonTests( }); test('overwritesContentInWriteMode', () async { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsStringSync('Hello'); - IOSink sink = f.openWrite(); + var sink = f.openWrite(); sink.write('Goodbye'); await sink.flush(); await sink.close(); @@ -2344,9 +2343,9 @@ void runCommonTests( }); test('appendsContentInAppendMode', () async { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsStringSync('Hello'); - IOSink sink = f.openWrite(mode: FileMode.append); + var sink = f.openWrite(mode: FileMode.append); sink.write('Goodbye'); await sink.flush(); await sink.close(); @@ -2354,12 +2353,12 @@ void runCommonTests( }); test('openWriteHandleDoesNotChange', () async { - File f = fs.file(ns('/foo'))..createSync(); - IOSink sink = f.openWrite(); + var f = fs.file(ns('/foo'))..createSync(); + var sink = f.openWrite(); sink.write('Hello'); await sink.flush(); - final File newFile = f.renameSync(ns('/bar')); + final newFile = f.renameSync(ns('/bar')); sink.write('Goodbye'); await sink.flush(); await sink.close(); @@ -2377,7 +2376,7 @@ void runCommonTests( late bool isSinkClosed; Future closeSink() { - Future future = sink.close(); + var future = sink.close(); isSinkClosed = true; return future; } @@ -2448,13 +2447,13 @@ void runCommonTests( test('ignoresCloseAfterAlreadyClosed', () async { sink.write('Hello world'); - Future f1 = closeSink(); - Future f2 = closeSink(); + var f1 = closeSink(); + var f2 = closeSink(); await Future.wait(>[f1, f2]); }); test('returnsAccurateDoneFuture', () async { - bool done = false; + var done = false; // ignore: unawaited_futures sink.done.then((dynamic _) => done = true); expect(done, isFalse); @@ -2469,7 +2468,7 @@ void runCommonTests( late bool isControllerClosed; Future closeController() { - Future future = controller.close(); + var future = controller.close(); isControllerClosed = true; return future; } @@ -2543,7 +2542,7 @@ void runCommonTests( }); test('succeedsIfExistsAsFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsBytesSync([1, 2, 3, 4]); expect(f.readAsBytesSync(), [1, 2, 3, 4]); }); @@ -2556,12 +2555,12 @@ void runCommonTests( }); test('returnsEmptyListForZeroByteFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); expect(f.readAsBytesSync(), isEmpty); }); test('returns a copy, not a view, of the file content', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsBytesSync([1, 2, 3, 4]); List result = f.readAsBytesSync(); expect(result, [1, 2, 3, 4]); @@ -2593,7 +2592,7 @@ void runCommonTests( }); test('succeedsIfExistsAsFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsStringSync('Hello world'); expect(f.readAsStringSync(), 'Hello world'); }); @@ -2606,14 +2605,14 @@ void runCommonTests( }); test('returnsEmptyStringForZeroByteFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); expect(f.readAsStringSync(), isEmpty); }); }); group('readAsLines', () { - const String testString = 'Hello world\nHow are you?\nI am fine'; - final List expectedLines = [ + const testString = 'Hello world\nHow are you?\nI am fine'; + final expectedLines = [ 'Hello world', 'How are you?', 'I am fine', @@ -2641,25 +2640,25 @@ void runCommonTests( }); test('succeedsIfExistsAsFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsStringSync(testString); expect(f.readAsLinesSync(), expectedLines); }); test('succeedsIfExistsAsLinkToFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); fs.link(ns('/bar')).createSync(ns('/foo')); f.writeAsStringSync(testString); expect(f.readAsLinesSync(), expectedLines); }); test('returnsEmptyListForZeroByteFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); expect(f.readAsLinesSync(), isEmpty); }); test('isTrailingNewlineAgnostic', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsStringSync('$testString\n'); expect(f.readAsLinesSync(), expectedLines); @@ -2677,7 +2676,7 @@ void runCommonTests( }); test('createsFileIfDoesntExist', () { - File f = fs.file(ns('/foo')); + var f = fs.file(ns('/foo')); expect(f, isNot(exists)); f.writeAsBytesSync([1, 2, 3, 4]); expect(f, exists); @@ -2699,21 +2698,21 @@ void runCommonTests( }); test('succeedsIfExistsAsLinkToFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); fs.link(ns('/bar')).createSync(ns('/foo')); fs.file(ns('/bar')).writeAsBytesSync([1, 2, 3, 4]); expect(f.readAsBytesSync(), [1, 2, 3, 4]); }); test('throwsIfFileModeRead', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); expectFileSystemException(ErrorCodes.EBADF, () { f.writeAsBytesSync([1], mode: FileMode.read); }); }); test('overwritesContentIfFileModeWrite', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsBytesSync([1, 2]); expect(f.readAsBytesSync(), [1, 2]); f.writeAsBytesSync([3, 4]); @@ -2721,7 +2720,7 @@ void runCommonTests( }); test('appendsContentIfFileModeAppend', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsBytesSync([1, 2], mode: FileMode.append); expect(f.readAsBytesSync(), [1, 2]); f.writeAsBytesSync([3, 4], mode: FileMode.append); @@ -2729,17 +2728,17 @@ void runCommonTests( }); test('acceptsEmptyBytesList', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsBytesSync([]); expect(f.readAsBytesSync(), []); }); test('updatesLastModifiedTime', () async { - File f = fs.file(ns('/foo'))..createSync(); - DateTime before = f.statSync().modified; + var f = fs.file(ns('/foo'))..createSync(); + var before = f.statSync().modified; await Future.delayed(const Duration(seconds: 2)); f.writeAsBytesSync([1, 2, 3]); - DateTime after = f.statSync().modified; + var after = f.statSync().modified; expect(after, isAfter(before)); }); }); @@ -2750,7 +2749,7 @@ void runCommonTests( }); test('createsFileIfDoesntExist', () { - File f = fs.file(ns('/foo')); + var f = fs.file(ns('/foo')); expect(f, isNot(exists)); f.writeAsStringSync('Hello world'); expect(f, exists); @@ -2772,21 +2771,21 @@ void runCommonTests( }); test('succeedsIfExistsAsLinkToFile', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); fs.link(ns('/bar')).createSync(ns('/foo')); fs.file(ns('/bar')).writeAsStringSync('Hello world'); expect(f.readAsStringSync(), 'Hello world'); }); test('throwsIfFileModeRead', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); expectFileSystemException(ErrorCodes.EBADF, () { f.writeAsStringSync('Hello world', mode: FileMode.read); }); }); test('overwritesContentIfFileModeWrite', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsStringSync('Hello world'); expect(f.readAsStringSync(), 'Hello world'); f.writeAsStringSync('Goodbye cruel world'); @@ -2794,7 +2793,7 @@ void runCommonTests( }); test('appendsContentIfFileModeAppend', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsStringSync('Hello', mode: FileMode.append); expect(f.readAsStringSync(), 'Hello'); f.writeAsStringSync('Goodbye', mode: FileMode.append); @@ -2802,7 +2801,7 @@ void runCommonTests( }); test('acceptsEmptyString', () { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); f.writeAsStringSync(''); expect(f.readAsStringSync(), isEmpty); }); @@ -2847,38 +2846,38 @@ void runCommonTests( group('stat', () { test('isNotFoundIfDoesntExistAtTail', () { - FileStat stat = fs.file(ns('/foo')).statSync(); + var stat = fs.file(ns('/foo')).statSync(); expect(stat.type, FileSystemEntityType.notFound); }); test('isNotFoundIfDoesntExistViaTraversal', () { - FileStat stat = fs.file(ns('/foo/bar')).statSync(); + var stat = fs.file(ns('/foo/bar')).statSync(); expect(stat.type, FileSystemEntityType.notFound); }); test('isDirectoryIfExistsAsDirectory', () { fs.directory(ns('/foo')).createSync(); - FileStat stat = fs.file(ns('/foo')).statSync(); + var stat = fs.file(ns('/foo')).statSync(); expect(stat.type, FileSystemEntityType.directory); }); test('isFileIfExistsAsFile', () { fs.file(ns('/foo')).createSync(); - FileStat stat = fs.file(ns('/foo')).statSync(); + var stat = fs.file(ns('/foo')).statSync(); expect(stat.type, FileSystemEntityType.file); }); test('isFileIfExistsAsLinkToFile', () { fs.file(ns('/foo')).createSync(); fs.link(ns('/bar')).createSync(ns('/foo')); - FileStat stat = fs.file(ns('/bar')).statSync(); + var stat = fs.file(ns('/bar')).statSync(); expect(stat.type, FileSystemEntityType.file); }); }); group('delete', () { test('returnsCovariantType', () async { - File f = fs.file(ns('/foo'))..createSync(); + var f = fs.file(ns('/foo'))..createSync(); expect(await f.delete(), isFile); }); @@ -2953,14 +2952,14 @@ void runCommonTests( group('uri', () { test('whenTargetIsDirectory', () { fs.directory(ns('/foo')).createSync(); - Link l = fs.link(ns('/bar'))..createSync(ns('/foo')); + var l = fs.link(ns('/bar'))..createSync(ns('/foo')); expect(l.uri, fs.path.toUri(ns('/bar'))); expect(fs.link('bar').uri.toString(), 'bar'); }); test('whenTargetIsFile', () { fs.file(ns('/foo')).createSync(); - Link l = fs.link(ns('/bar'))..createSync(ns('/foo')); + var l = fs.link(ns('/bar'))..createSync(ns('/foo')); expect(l.uri, fs.path.toUri(ns('/bar'))); expect(fs.link('bar').uri.toString(), 'bar'); }); @@ -2991,24 +2990,24 @@ void runCommonTests( }); test('isTrueIfTargetIsNotFound', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); expect(l, exists); }); test('isTrueIfTargetIsFile', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.file(ns('/bar')).createSync(); expect(l, exists); }); test('isTrueIfTargetIsDirectory', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.directory(ns('/bar')).createSync(); expect(l, exists); }); test('isTrueIfTargetIsLinkLoop', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.link(ns('/bar')).createSync(ns('/foo')); expect(l, exists); }); @@ -3038,29 +3037,29 @@ void runCommonTests( }); test('isNotFoundIfTargetNotFoundAtTail', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); expect(l.statSync().type, FileSystemEntityType.notFound); }); test('isNotFoundIfTargetNotFoundViaTraversal', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar/baz')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar/baz')); expect(l.statSync().type, FileSystemEntityType.notFound); }); test('isNotFoundIfTargetIsLinkLoop', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.link(ns('/bar')).createSync(ns('/foo')); expect(l.statSync().type, FileSystemEntityType.notFound); }); test('isFileIfTargetIsFile', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.file(ns('/bar')).createSync(); expect(l.statSync().type, FileSystemEntityType.file); }); test('isDirectoryIfTargetIsDirectory', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.directory(ns('/bar')).createSync(); expect(l.statSync().type, FileSystemEntityType.directory); }); @@ -3068,7 +3067,7 @@ void runCommonTests( group('delete', () { test('returnsCovariantType', () async { - Link link = fs.link(ns('/foo'))..createSync(ns('/bar')); + var link = fs.link(ns('/foo'))..createSync(ns('/bar')); expect(await link.delete(), isLink); }); @@ -3118,7 +3117,7 @@ void runCommonTests( }); test('unlinksIfTargetIsFileAndRecursiveFalse', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.file(ns('/bar')).createSync(); l.deleteSync(); expect(fs.typeSync(ns('/foo'), followLinks: false), @@ -3128,7 +3127,7 @@ void runCommonTests( }); test('unlinksIfTargetIsFileAndRecursiveTrue', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.file(ns('/bar')).createSync(); l.deleteSync(recursive: true); expect(fs.typeSync(ns('/foo'), followLinks: false), @@ -3138,7 +3137,7 @@ void runCommonTests( }); test('unlinksIfTargetIsDirectoryAndRecursiveFalse', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.directory(ns('/bar')).createSync(); l.deleteSync(); expect(fs.typeSync(ns('/foo'), followLinks: false), @@ -3148,7 +3147,7 @@ void runCommonTests( }); test('unlinksIfTargetIsDirectoryAndRecursiveTrue', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.directory(ns('/bar')).createSync(); l.deleteSync(recursive: true); expect(fs.typeSync(ns('/foo'), followLinks: false), @@ -3158,7 +3157,7 @@ void runCommonTests( }); test('unlinksIfTargetIsLinkLoop', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.link(ns('/bar')).createSync(ns('/foo')); l.deleteSync(); expect(fs.typeSync(ns('/foo'), followLinks: false), @@ -3178,7 +3177,7 @@ void runCommonTests( }); test('ignoresLinkTarget', () { - Link l = fs.link(ns('/foo/bar')) + var l = fs.link(ns('/foo/bar')) ..createSync(ns('/baz/qux'), recursive: true); expect(l.parent.path, ns('/foo')); }); @@ -3190,7 +3189,7 @@ void runCommonTests( }); test('succeedsIfLinkDoesntExistAtTail', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); expect(fs.typeSync(ns('/foo'), followLinks: false), FileSystemEntityType.link); expect(l.targetSync(), ns('/bar')); @@ -3203,7 +3202,7 @@ void runCommonTests( }); test('succeedsIfLinkDoesntExistViaTraversalAndRecursiveTrue', () { - Link l = fs.link(ns('/foo/bar'))..createSync('baz', recursive: true); + var l = fs.link(ns('/foo/bar'))..createSync('baz', recursive: true); expect(fs.typeSync(ns('/foo'), followLinks: false), FileSystemEntityType.directory); expect(fs.typeSync(ns('/foo/bar'), followLinks: false), @@ -3242,7 +3241,7 @@ void runCommonTests( group('update', () { test('returnsCovariantType', () async { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); expect(await l.update(ns('/baz')), isLink); }); @@ -3336,24 +3335,24 @@ void runCommonTests( }); test('succeedsIfTargetIsNotFound', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); expect(l.targetSync(), ns('/bar')); }); test('succeedsIfTargetIsFile', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.file(ns('/bar')).createSync(); expect(l.targetSync(), ns('/bar')); }); test('succeedsIfTargetIsDirectory', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.directory(ns('/bar')).createSync(); expect(l.targetSync(), ns('/bar')); }); test('succeedsIfTargetIsLinkLoop', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.link(ns('/bar')).createSync(ns('/foo')); expect(l.targetSync(), ns('/bar')); }); @@ -3393,9 +3392,9 @@ void runCommonTests( }); test('succeedsIfSourceIsLinkToFile', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.file(ns('/bar')).createSync(); - Link renamed = l.renameSync(ns('/baz')); + var renamed = l.renameSync(ns('/baz')); expect(renamed.path, ns('/baz')); expect(fs.typeSync(ns('/foo'), followLinks: false), FileSystemEntityType.notFound); @@ -3407,8 +3406,8 @@ void runCommonTests( }); test('succeedsIfSourceIsLinkToNotFound', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); - Link renamed = l.renameSync(ns('/baz')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var renamed = l.renameSync(ns('/baz')); expect(renamed.path, ns('/baz')); expect(fs.typeSync(ns('/foo'), followLinks: false), FileSystemEntityType.notFound); @@ -3418,9 +3417,9 @@ void runCommonTests( }); test('succeedsIfSourceIsLinkToDirectory', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.directory(ns('/bar')).createSync(); - Link renamed = l.renameSync(ns('/baz')); + var renamed = l.renameSync(ns('/baz')); expect(renamed.path, ns('/baz')); expect(fs.typeSync(ns('/foo'), followLinks: false), FileSystemEntityType.notFound); @@ -3432,9 +3431,9 @@ void runCommonTests( }); test('succeedsIfSourceIsLinkLoop', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.link(ns('/bar')).createSync(ns('/foo')); - Link renamed = l.renameSync(ns('/baz')); + var renamed = l.renameSync(ns('/baz')); expect(renamed.path, ns('/baz')); expect(fs.typeSync(ns('/foo'), followLinks: false), FileSystemEntityType.notFound); @@ -3446,22 +3445,22 @@ void runCommonTests( }); test('succeedsIfDestinationDoesntExistAtTail', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); - Link renamed = l.renameSync(ns('/baz')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var renamed = l.renameSync(ns('/baz')); expect(renamed.path, ns('/baz')); expect(fs.link(ns('/foo')), isNot(exists)); expect(fs.link(ns('/baz')), exists); }); test('throwsIfDestinationDoesntExistViaTraversal', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); expectFileSystemException(ErrorCodes.ENOENT, () { l.renameSync(ns('/baz/qux')); }); }); test('throwsIfDestinationExistsAsFile', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.file(ns('/baz')).createSync(); expectFileSystemException(ErrorCodes.EINVAL, () { l.renameSync(ns('/baz')); @@ -3469,7 +3468,7 @@ void runCommonTests( }); test('throwsIfDestinationExistsAsDirectory', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.directory(ns('/baz')).createSync(); expectFileSystemException(ErrorCodes.EINVAL, () { l.renameSync(ns('/baz')); @@ -3477,7 +3476,7 @@ void runCommonTests( }); test('succeedsIfDestinationExistsAsLinkToFile', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.file(ns('/baz')).createSync(); fs.link(ns('/qux')).createSync(ns('/baz')); l.renameSync(ns('/qux')); @@ -3490,7 +3489,7 @@ void runCommonTests( }); test('throwsIfDestinationExistsAsLinkToDirectory', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.directory(ns('/baz')).createSync(); fs.link(ns('/qux')).createSync(ns('/baz')); l.renameSync(ns('/qux')); @@ -3503,7 +3502,7 @@ void runCommonTests( }); test('succeedsIfDestinationExistsAsLinkToNotFound', () { - Link l = fs.link(ns('/foo'))..createSync(ns('/bar')); + var l = fs.link(ns('/foo'))..createSync(ns('/bar')); fs.link(ns('/baz')).createSync(ns('/qux')); l.renameSync(ns('/baz')); expect(fs.typeSync(ns('/foo')), FileSystemEntityType.notFound); diff --git a/pkgs/file/test/local_test.dart b/pkgs/file/test/local_test.dart index e1618d230..b794ccd7b 100644 --- a/pkgs/file/test/local_test.dart +++ b/pkgs/file/test/local_test.dart @@ -2,7 +2,11 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +// ignore_for_file: lines_longer_than_80_chars + @TestOn('vm') +library; + import 'dart:io' as io; import 'package:file/local.dart'; @@ -33,7 +37,7 @@ void main() { setUpAll(() { if (io.Platform.isWindows) { // TODO(tvolkert): Remove once all more serious test failures are fixed - // https://github.com/google/file.dart/issues/56 + // https://github.com/dart-lang/tools/issues/618 ignoreOsErrorCodes = true; } }); @@ -42,7 +46,7 @@ void main() { ignoreOsErrorCodes = false; }); - Map> skipOnPlatform = >{ + var skipOnPlatform = >{ 'windows': [ 'FileSystem > currentDirectory > throwsIfHasNonExistentPathInComplexChain', 'FileSystem > currentDirectory > resolvesLinksIfEncountered', diff --git a/pkgs/file/test/memory_operations_test.dart b/pkgs/file/test/memory_operations_test.dart index 5e27843b5..916707c62 100644 --- a/pkgs/file/test/memory_operations_test.dart +++ b/pkgs/file/test/memory_operations_test.dart @@ -2,22 +2,21 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:test/test.dart'; void main() { test('Read operations invoke opHandle', () async { - List contexts = []; - List operations = []; - MemoryFileSystem fs = MemoryFileSystem.test( + var contexts = []; + var operations = []; + var fs = MemoryFileSystem.test( opHandle: (String context, FileSystemOp operation) { if (operation == FileSystemOp.read) { contexts.add(context); operations.add(operation); } }); - final File file = fs.file('test')..createSync(); + final file = fs.file('test')..createSync(); await file.readAsBytes(); file.readAsBytesSync(); @@ -34,16 +33,16 @@ void main() { }); test('Write operations invoke opHandle', () async { - List contexts = []; - List operations = []; - MemoryFileSystem fs = MemoryFileSystem.test( + var contexts = []; + var operations = []; + var fs = MemoryFileSystem.test( opHandle: (String context, FileSystemOp operation) { if (operation == FileSystemOp.write) { contexts.add(context); operations.add(operation); } }); - final File file = fs.file('test')..createSync(); + final file = fs.file('test')..createSync(); await file.writeAsBytes([]); file.writeAsBytesSync([]); @@ -60,18 +59,18 @@ void main() { }); test('Delete operations invoke opHandle', () async { - List contexts = []; - List operations = []; - MemoryFileSystem fs = MemoryFileSystem.test( + var contexts = []; + var operations = []; + var fs = MemoryFileSystem.test( opHandle: (String context, FileSystemOp operation) { if (operation == FileSystemOp.delete) { contexts.add(context); operations.add(operation); } }); - final File file = fs.file('test')..createSync(); - final Directory directory = fs.directory('testDir')..createSync(); - final Link link = fs.link('testLink')..createSync('foo'); + final file = fs.file('test')..createSync(); + final directory = fs.directory('testDir')..createSync(); + final link = fs.link('testLink')..createSync('foo'); await file.delete(); file.createSync(); @@ -98,9 +97,9 @@ void main() { }); test('Create operations invoke opHandle', () async { - List contexts = []; - List operations = []; - MemoryFileSystem fs = MemoryFileSystem.test( + var contexts = []; + var operations = []; + var fs = MemoryFileSystem.test( opHandle: (String context, FileSystemOp operation) { if (operation == FileSystemOp.create) { contexts.add(context); @@ -139,16 +138,16 @@ void main() { }); test('Open operations invoke opHandle', () async { - List contexts = []; - List operations = []; - MemoryFileSystem fs = MemoryFileSystem.test( + var contexts = []; + var operations = []; + var fs = MemoryFileSystem.test( opHandle: (String context, FileSystemOp operation) { if (operation == FileSystemOp.open) { contexts.add(context); operations.add(operation); } }); - final File file = fs.file('test')..createSync(); + final file = fs.file('test')..createSync(); await file.open(); file.openSync(); @@ -165,16 +164,16 @@ void main() { }); test('Copy operations invoke opHandle', () async { - List contexts = []; - List operations = []; - MemoryFileSystem fs = MemoryFileSystem.test( + var contexts = []; + var operations = []; + var fs = MemoryFileSystem.test( opHandle: (String context, FileSystemOp operation) { if (operation == FileSystemOp.copy) { contexts.add(context); operations.add(operation); } }); - final File file = fs.file('test')..createSync(); + final file = fs.file('test')..createSync(); await file.copy('A'); file.copySync('B'); @@ -187,9 +186,9 @@ void main() { }); test('Exists operations invoke opHandle', () async { - List contexts = []; - List operations = []; - MemoryFileSystem fs = MemoryFileSystem.test( + var contexts = []; + var operations = []; + var fs = MemoryFileSystem.test( opHandle: (String context, FileSystemOp operation) { if (operation == FileSystemOp.exists) { contexts.add(context); diff --git a/pkgs/file/test/memory_test.dart b/pkgs/file/test/memory_test.dart index f3b324e6c..ce8675f2e 100644 --- a/pkgs/file/test/memory_test.dart +++ b/pkgs/file/test/memory_test.dart @@ -66,8 +66,7 @@ void main() { }); test('MemoryFileSystem.test', () { - final MemoryFileSystem fs = - MemoryFileSystem.test(); // creates root directory + final fs = MemoryFileSystem.test(); // creates root directory fs.file('/test1.txt').createSync(); // creates file fs.file('/test2.txt').createSync(); // creates file expect(fs.directory('/').statSync().modified, DateTime(2000, 1, 1, 0, 1)); @@ -95,10 +94,10 @@ void main() { }); test('MemoryFile.openSync returns a MemoryRandomAccessFile', () async { - final MemoryFileSystem fs = MemoryFileSystem.test(); + final fs = MemoryFileSystem.test(); final io.File file = fs.file('/test1')..createSync(); - io.RandomAccessFile raf = file.openSync(); + var raf = file.openSync(); try { expect(raf, isA()); } finally { @@ -114,7 +113,7 @@ void main() { }); test('MemoryFileSystem.systemTempDirectory test', () { - final MemoryFileSystem fs = MemoryFileSystem.test(); + final fs = MemoryFileSystem.test(); final io.Directory fooA = fs.systemTempDirectory.createTempSync('foo'); final io.Directory fooB = fs.systemTempDirectory.createTempSync('foo'); @@ -122,7 +121,7 @@ void main() { expect(fooA.path, '/.tmp_rand0/foorand0'); expect(fooB.path, '/.tmp_rand0/foorand1'); - final MemoryFileSystem secondFs = MemoryFileSystem.test(); + final secondFs = MemoryFileSystem.test(); final io.Directory fooAA = secondFs.systemTempDirectory.createTempSync('foo'); @@ -136,16 +135,16 @@ void main() { test('Failed UTF8 decoding in MemoryFileSystem throws a FileSystemException', () { - final MemoryFileSystem fileSystem = MemoryFileSystem.test(); - final File file = fileSystem.file('foo') + final fileSystem = MemoryFileSystem.test(); + final file = fileSystem.file('foo') ..writeAsBytesSync([0xFFFE]); // Invalid UTF8 expect(file.readAsStringSync, throwsA(isA())); }); test('Creating a temporary directory actually creates the directory', () { - final MemoryFileSystem fileSystem = MemoryFileSystem.test(); - final Directory tempDir = fileSystem.currentDirectory.createTempSync('foo'); + final fileSystem = MemoryFileSystem.test(); + final tempDir = fileSystem.currentDirectory.createTempSync('foo'); expect(tempDir.existsSync(), true); }); diff --git a/pkgs/file/test/utils.dart b/pkgs/file/test/utils.dart index 231312fbe..797ec9de5 100644 --- a/pkgs/file/test/utils.dart +++ b/pkgs/file/test/utils.dart @@ -25,7 +25,7 @@ DateTime floor([DateTime? time]) { /// If [time] is not specified, it will default to the current time. DateTime ceil([DateTime? time]) { time ??= DateTime.now(); - int microseconds = (1000 * time.millisecond) + time.microsecond; + var microseconds = (1000 * time.millisecond) + time.microsecond; return (microseconds == 0) ? time // Add just enough milliseconds and microseconds to reach the next second. @@ -78,7 +78,7 @@ abstract class _CompareDateTime extends Matcher { bool verbose, ) { if (item is DateTime) { - Duration diff = item.difference(_time).abs(); + var diff = item.difference(_time).abs(); return description.add('is $mismatchAdjective $_time by $diff'); } else { return description.add('is not a DateTime'); diff --git a/pkgs/file/test/utils_test.dart b/pkgs/file/test/utils_test.dart index 75293bf3c..23788e983 100644 --- a/pkgs/file/test/utils_test.dart +++ b/pkgs/file/test/utils_test.dart @@ -8,9 +8,9 @@ import 'utils.dart'; void main() { test('floorAndCeilProduceExactSecondDateTime', () { - DateTime time = DateTime.fromMicrosecondsSinceEpoch(1001); - DateTime lower = floor(time); - DateTime upper = ceil(time); + var time = DateTime.fromMicrosecondsSinceEpoch(1001); + var lower = floor(time); + var upper = ceil(time); expect(lower.millisecond, 0); expect(upper.millisecond, 0); expect(lower.microsecond, 0); @@ -18,26 +18,26 @@ void main() { }); test('floorAndCeilWorkWithNow', () { - DateTime time = DateTime.now(); - int lower = time.difference(floor(time)).inMicroseconds; - int upper = ceil(time).difference(time).inMicroseconds; + var time = DateTime.now(); + var lower = time.difference(floor(time)).inMicroseconds; + var upper = ceil(time).difference(time).inMicroseconds; expect(lower, lessThan(1000000)); expect(upper, lessThanOrEqualTo(1000000)); }); test('floorAndCeilWorkWithExactSecondDateTime', () { - DateTime time = DateTime.parse('1999-12-31 23:59:59'); - DateTime lower = floor(time); - DateTime upper = ceil(time); + var time = DateTime.parse('1999-12-31 23:59:59'); + var lower = floor(time); + var upper = ceil(time); expect(lower, time); expect(upper, time); }); test('floorAndCeilWorkWithInexactSecondDateTime', () { - DateTime time = DateTime.parse('1999-12-31 23:59:59.500'); - DateTime lower = floor(time); - DateTime upper = ceil(time); - Duration difference = upper.difference(lower); + var time = DateTime.parse('1999-12-31 23:59:59.500'); + var lower = floor(time); + var upper = ceil(time); + var difference = upper.difference(lower); expect(difference.inMicroseconds, 1000000); }); } diff --git a/pkgs/file_testing/CHANGELOG.md b/pkgs/file_testing/CHANGELOG.md index 0af779d87..17039ee16 100644 --- a/pkgs/file_testing/CHANGELOG.md +++ b/pkgs/file_testing/CHANGELOG.md @@ -1,3 +1,8 @@ +## 3.1.0-wip + +* Changed the type of several matchers to `TypeMatcher` which allows cascading + their usage with `.having` and similar. + ## 3.0.2 * Require Dart 3.1. diff --git a/pkgs/file_testing/analysis_options.yaml b/pkgs/file_testing/analysis_options.yaml index 8fbd2e443..d978f811c 100644 --- a/pkgs/file_testing/analysis_options.yaml +++ b/pkgs/file_testing/analysis_options.yaml @@ -1,6 +1 @@ -include: package:lints/recommended.yaml - -analyzer: - errors: - # Allow having TODOs in the code - todo: ignore +include: package:dart_flutter_team_lints/analysis_options.yaml diff --git a/pkgs/file_testing/lib/src/testing/core_matchers.dart b/pkgs/file_testing/lib/src/testing/core_matchers.dart index f58539f19..801209e89 100644 --- a/pkgs/file_testing/lib/src/testing/core_matchers.dart +++ b/pkgs/file_testing/lib/src/testing/core_matchers.dart @@ -2,6 +2,8 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +// ignore_for_file: comment_references + import 'dart:io'; import 'package:test/test.dart'; @@ -9,26 +11,27 @@ import 'package:test/test.dart'; import 'internal.dart'; /// Matcher that successfully matches against any instance of [Directory]. -const Matcher isDirectory = TypeMatcher(); +const isDirectory = TypeMatcher(); /// Matcher that successfully matches against any instance of [File]. -const Matcher isFile = TypeMatcher(); +const isFile = TypeMatcher(); /// Matcher that successfully matches against any instance of [Link]. -const Matcher isLink = TypeMatcher(); +const isLink = TypeMatcher(); /// Matcher that successfully matches against any instance of /// [FileSystemEntity]. -const Matcher isFileSystemEntity = TypeMatcher(); +const isFileSystemEntity = TypeMatcher(); /// Matcher that successfully matches against any instance of [FileStat]. -const Matcher isFileStat = TypeMatcher(); +const isFileStat = TypeMatcher(); /// Returns a [Matcher] that matches [path] against an entity's path. /// /// [path] may be a String, a predicate function, or a [Matcher]. If it is /// a String, it will be wrapped in an equality matcher. -Matcher hasPath(dynamic path) => _HasPath(path); +TypeMatcher hasPath(dynamic path) => + isFileSystemEntity.having((e) => e.path, 'path', path); /// Returns a [Matcher] that successfully matches against an instance of /// [FileSystemException]. @@ -39,7 +42,8 @@ Matcher hasPath(dynamic path) => _HasPath(path); /// [osErrorCode] may be an `int`, a predicate function, or a [Matcher]. If it /// is an `int`, it will be wrapped in an equality matcher. Matcher isFileSystemException([dynamic osErrorCode]) => - _FileSystemException(osErrorCode); + const TypeMatcher().having((e) => e.osError?.errorCode, + 'osError.errorCode', _fileExceptionWrapMatcher(osErrorCode)); /// Returns a matcher that successfully matches against a future or function /// that throws a [FileSystemException]. @@ -67,89 +71,10 @@ void expectFileSystemException(dynamic osErrorCode, void Function() callback) { /// Matcher that successfully matches against a [FileSystemEntity] that /// exists ([FileSystemEntity.existsSync] returns true). -const Matcher exists = _Exists(); - -class _FileSystemException extends Matcher { - _FileSystemException(dynamic osErrorCode) - : _matcher = _wrapMatcher(osErrorCode); - - final Matcher? _matcher; - - static Matcher? _wrapMatcher(dynamic osErrorCode) { - if (osErrorCode == null) { - return null; - } - return ignoreOsErrorCodes ? anything : wrapMatcher(osErrorCode); - } - - @override - bool matches(dynamic item, Map matchState) { - if (item is FileSystemException) { - return _matcher == null || - _matcher!.matches(item.osError?.errorCode, matchState); - } - return false; - } - - @override - Description describe(Description desc) { - if (_matcher == null) { - return desc.add('FileSystemException'); - } else { - desc.add('FileSystemException with osError.errorCode: '); - return _matcher!.describe(desc); - } - } -} - -class _HasPath extends Matcher { - _HasPath(dynamic path) : _matcher = wrapMatcher(path); - - final Matcher _matcher; +final TypeMatcher exists = + isFileSystemEntity.having((e) => e.existsSync(), 'existsSync', true); - @override - bool matches(dynamic item, Map matchState) => - _matcher.matches(item.path, matchState); - - @override - Description describe(Description desc) { - desc.add('has path: '); - return _matcher.describe(desc); - } - - @override - Description describeMismatch( - dynamic item, - Description desc, - Map matchState, - bool verbose, - ) { - desc.add('has path: \'${item.path}\'').add('\n Which: '); - final Description pathDesc = StringDescription(); - _matcher.describeMismatch(item.path, pathDesc, matchState, verbose); - desc.add(pathDesc.toString()); - return desc; - } -} - -class _Exists extends Matcher { - const _Exists(); - - @override - bool matches(dynamic item, Map matchState) => - item is FileSystemEntity && item.existsSync(); - - @override - Description describe(Description description) => - description.add('a file system entity that exists'); - - @override - Description describeMismatch( - dynamic item, - Description description, - Map matchState, - bool verbose, - ) { - return description.add('does not exist'); - } -} +Matcher? _fileExceptionWrapMatcher(dynamic osErrorCode) => + (osErrorCode == null || ignoreOsErrorCodes) + ? anything + : wrapMatcher(osErrorCode); diff --git a/pkgs/file_testing/pubspec.yaml b/pkgs/file_testing/pubspec.yaml index 691efa0e6..895826a39 100644 --- a/pkgs/file_testing/pubspec.yaml +++ b/pkgs/file_testing/pubspec.yaml @@ -1,5 +1,5 @@ name: file_testing -version: 3.0.2 +version: 3.1.0-wip description: Testing utilities for package:file. repository: https://github.com/dart-lang/tools/tree/main/pkgs/file_testing issue_tracker: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Afile_testing @@ -10,5 +10,5 @@ environment: dependencies: test: ^1.23.1 -dev_dependencies: - lints: ^5.0.0 +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 diff --git a/pkgs/package_config/.gitignore b/pkgs/package_config/.gitignore new file mode 100644 index 000000000..7b888b84c --- /dev/null +++ b/pkgs/package_config/.gitignore @@ -0,0 +1,7 @@ +.packages +.pub +.dart_tool/ +.vscode/ +packages +pubspec.lock +doc/api/ diff --git a/pkgs/package_config/AUTHORS b/pkgs/package_config/AUTHORS new file mode 100644 index 000000000..e8063a8cd --- /dev/null +++ b/pkgs/package_config/AUTHORS @@ -0,0 +1,6 @@ +# Below is a list of people and organizations that have contributed +# to the project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. diff --git a/pkgs/package_config/CHANGELOG.md b/pkgs/package_config/CHANGELOG.md new file mode 100644 index 000000000..101a0fe76 --- /dev/null +++ b/pkgs/package_config/CHANGELOG.md @@ -0,0 +1,108 @@ +## 2.1.1 + +- Require Dart 3.4 +- Move to `dart-lang/tools` monorepo. + +## 2.1.0 + +- Adds `minVersion` to `findPackageConfig` and `findPackageConfigVersion` + which allows ignoring earlier versions (which currently only means + ignoring version 1, aka. `.packages` files.) + +- Changes the version number of `SimplePackageConfig.empty` to the + current maximum version. + +- Improve file read performance; improve lookup performance. +- Emit an error when a package is inside the package root of another package. +- Fix a link in the readme. + +## 2.0.2 + +- Update package description and README. +- Change to package:lints for style checking. +- Add an example. + +## 2.0.1 + +- Use unique library names to correct docs issue. + +## 2.0.0 + +- Migrate to null safety. +- Remove legacy APIs. +- Adds `relativeRoot` property to `Package` which controls whether to + make the root URI relative when writing a configuration file. + +## 1.9.3 + +- Fix `Package` constructor not accepting relative `packageUriRoot`. + +## 1.9.2 + +- Updated to support new rules for picking `package_config.json` over + a specified `.packages`. +- Deduce package root from `.packages` derived package configuration, + and default all such packages to language version 2.7. + +## 1.9.1 + +- Remove accidental transitive import of `dart:io` from entrypoints that are + supposed to be cross-platform compatible. + +## 1.9.0 + +- Based on new JSON file format with more content. +- This version includes all the new functionality intended for a 2.0.0 + version, as well as the, now deprecated, version 1 functionality. + When we release 2.0.0, the deprecated functionality will be removed. + +## 1.1.0 + +- Allow parsing files with default-package entries and metadata. + A default-package entry has an empty key and a valid package name + as value. + Metadata is attached as fragments to base URIs. + +## 1.0.5 + +- Fix usage of SDK constants. + +## 1.0.4 + +- Set max SDK version to <3.0.0. + +## 1.0.3 + +- Removed unneeded dependency constraint on SDK. + +## 1.0.2 + +- Update SDK constraint to be 2.0.0 dev friendly. + +## 1.0.1 + +- Fix test to not write to sink after it's closed. + +## 1.0.0 + +- Public API marked stable. + +## 0.1.5 + +- `FilePackagesDirectoryPackages.getBase(..)` performance improvements. + +## 0.1.4 + +- Strong mode fixes. + +## 0.1.3 + +- Invalid test cleanup (to keep up with changes in `Uri`). + +## 0.1.1 + +- Syntax updates. + +## 0.1.0 + +- Initial implementation. diff --git a/pkgs/package_config/LICENSE b/pkgs/package_config/LICENSE new file mode 100644 index 000000000..767000764 --- /dev/null +++ b/pkgs/package_config/LICENSE @@ -0,0 +1,27 @@ +Copyright 2019, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/package_config/README.md b/pkgs/package_config/README.md new file mode 100644 index 000000000..76fd3cbed --- /dev/null +++ b/pkgs/package_config/README.md @@ -0,0 +1,26 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/package_config.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/package_config.yaml) +[![pub package](https://img.shields.io/pub/v/package_config.svg)](https://pub.dev/packages/package_config) +[![package publisher](https://img.shields.io/pub/publisher/package_config.svg)](https://pub.dev/packages/package_config/publisher) + +Support for working with **Package Configuration** files as described +in the Package Configuration v2 [design document](https://github.com/dart-lang/language/blob/master/accepted/2.8/language-versioning/package-config-file-v2.md). + +A Dart package configuration file is used to resolve Dart package names (e.g. +`foobar`) to Dart files containing the source code for that package (e.g. +`file:///Users/myuser/.pub-cache/hosted/pub.dartlang.org/foobar-1.1.0`). The +standard package configuration file is `.dart_tool/package_config.json`, and is +written by the Dart tool when the command `dart pub get` is run. + +The primary libraries of this package are +* `package_config.dart`: + Defines the `PackageConfig` class and other types needed to use + package configurations, and provides functions to find, read and + write package configuration files. + +* `package_config_types.dart`: + Just the `PackageConfig` class and other types needed to use + package configurations. This library does not depend on `dart:io`. + +The package includes deprecated backwards compatible functionality to +work with the `.packages` file. This functionality will not be maintained, +and will be removed in a future version of this package. diff --git a/pkgs/package_config/analysis_options.yaml b/pkgs/package_config/analysis_options.yaml new file mode 100644 index 000000000..c0249e5e1 --- /dev/null +++ b/pkgs/package_config/analysis_options.yaml @@ -0,0 +1,5 @@ +# Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +# for details. All rights reserved. Use of this source code is governed by a +# BSD-style license that can be found in the LICENSE file. + +include: package:dart_flutter_team_lints/analysis_options.yaml diff --git a/pkgs/package_config/example/main.dart b/pkgs/package_config/example/main.dart new file mode 100644 index 000000000..db137caf4 --- /dev/null +++ b/pkgs/package_config/example/main.dart @@ -0,0 +1,19 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:io' show Directory; + +import 'package:package_config/package_config.dart'; + +void main() async { + var packageConfig = await findPackageConfig(Directory.current); + if (packageConfig == null) { + print('Failed to locate or read package config.'); + } else { + print('This package depends on ${packageConfig.packages.length} packages:'); + for (var package in packageConfig.packages) { + print('- ${package.name}'); + } + } +} diff --git a/pkgs/package_config/lib/package_config.dart b/pkgs/package_config/lib/package_config.dart new file mode 100644 index 000000000..074c97707 --- /dev/null +++ b/pkgs/package_config/lib/package_config.dart @@ -0,0 +1,199 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// A package configuration is a way to assign file paths to package URIs, +/// and vice-versa. +/// +/// This package provides functionality to find, read and write package +/// configurations in the [specified format](https://github.com/dart-lang/language/blob/master/accepted/future-releases/language-versioning/package-config-file-v2.md). +library; + +import 'dart:io' show Directory, File; +import 'dart:typed_data' show Uint8List; + +import 'src/discovery.dart' as discover; +import 'src/errors.dart' show throwError; +import 'src/package_config.dart'; +import 'src/package_config_io.dart'; + +export 'package_config_types.dart'; + +/// Reads a specific package configuration file. +/// +/// The file must exist and be readable. +/// It must be either a valid `package_config.json` file +/// or a valid `.packages` file. +/// It is considered a `package_config.json` file if its first character +/// is a `{`. +/// +/// If the file is a `.packages` file (the file name is `.packages`) +/// and [preferNewest] is true, the default, also checks if there is +/// a `.dart_tool/package_config.json` file next +/// to the original file, and if so, loads that instead. +/// If [preferNewest] is set to false, a directly specified `.packages` file +/// is loaded even if there is an available `package_config.json` file. +/// The caller can determine this from the [PackageConfig.version] +/// being 1 and look for a `package_config.json` file themselves. +/// +/// If [onError] is provided, the configuration file parsing will report errors +/// by calling that function, and then try to recover. +/// The returned package configuration is a *best effort* attempt to create +/// a valid configuration from the invalid configuration file. +/// If no [onError] is provided, errors are thrown immediately. +Future loadPackageConfig(File file, + {bool preferNewest = true, void Function(Object error)? onError}) => + readAnyConfigFile(file, preferNewest, onError ?? throwError); + +/// Reads a specific package configuration URI. +/// +/// The file of the URI must exist and be readable. +/// It must be either a valid `package_config.json` file +/// or a valid `.packages` file. +/// It is considered a `package_config.json` file if its first +/// non-whitespace character is a `{`. +/// +/// If [preferNewest] is true, the default, and the file is a `.packages` file, +/// as determined by its file name being `.packages`, +/// first checks if there is a `.dart_tool/package_config.json` file +/// next to the original file, and if so, loads that instead. +/// The [file] *must not* be a `package:` URI. +/// If [preferNewest] is set to false, a directly specified `.packages` file +/// is loaded even if there is an available `package_config.json` file. +/// The caller can determine this from the [PackageConfig.version] +/// being 1 and look for a `package_config.json` file themselves. +/// +/// If [loader] is provided, URIs are loaded using that function. +/// The future returned by the loader must complete with a [Uint8List] +/// containing the entire file content encoded as UTF-8, +/// or with `null` if the file does not exist. +/// The loader may throw at its own discretion, for situations where +/// it determines that an error might be need user attention, +/// but it is always allowed to return `null`. +/// This function makes no attempt to catch such errors. +/// As such, it may throw any error that [loader] throws. +/// +/// If no [loader] is supplied, a default loader is used which +/// only accepts `file:`, `http:` and `https:` URIs, +/// and which uses the platform file system and HTTP requests to +/// fetch file content. The default loader never throws because +/// of an I/O issue, as long as the location URIs are valid. +/// As such, it does not distinguish between a file not existing, +/// and it being temporarily locked or unreachable. +/// +/// If [onError] is provided, the configuration file parsing will report errors +/// by calling that function, and then try to recover. +/// The returned package configuration is a *best effort* attempt to create +/// a valid configuration from the invalid configuration file. +/// If no [onError] is provided, errors are thrown immediately. +Future loadPackageConfigUri(Uri file, + {Future Function(Uri uri)? loader, + bool preferNewest = true, + void Function(Object error)? onError}) => + readAnyConfigFileUri(file, loader, onError ?? throwError, preferNewest); + +/// Finds a package configuration relative to [directory]. +/// +/// If [directory] contains a package configuration, +/// either a `.dart_tool/package_config.json` file or, +/// if not, a `.packages`, then that file is loaded. +/// +/// If no file is found in the current directory, +/// then the parent directories are checked recursively, +/// all the way to the root directory, to check if those contains +/// a package configuration. +/// If [recurse] is set to `false`, this parent directory check is not +/// performed. +/// +/// If [onError] is provided, the configuration file parsing will report errors +/// by calling that function, and then try to recover. +/// The returned package configuration is a *best effort* attempt to create +/// a valid configuration from the invalid configuration file. +/// If no [onError] is provided, errors are thrown immediately. +/// +/// If [minVersion] is set to something greater than its default, +/// any lower-version configuration files are ignored in the search. +/// +/// Returns `null` if no configuration file is found. +Future findPackageConfig(Directory directory, + {bool recurse = true, + void Function(Object error)? onError, + int minVersion = 1}) { + if (minVersion > PackageConfig.maxVersion) { + throw ArgumentError.value(minVersion, 'minVersion', + 'Maximum known version is ${PackageConfig.maxVersion}'); + } + return discover.findPackageConfig( + directory, minVersion, recurse, onError ?? throwError); +} + +/// Finds a package configuration relative to [location]. +/// +/// If [location] contains a package configuration, +/// either a `.dart_tool/package_config.json` file or, +/// if not, a `.packages`, then that file is loaded. +/// The [location] URI *must not* be a `package:` URI. +/// It should be a hierarchical URI which is supported +/// by [loader]. +/// +/// If no file is found in the current directory, +/// then the parent directories are checked recursively, +/// all the way to the root directory, to check if those contains +/// a package configuration. +/// If [recurse] is set to `false`, this parent directory check is not +/// performed. +/// +/// If [loader] is provided, URIs are loaded using that function. +/// The future returned by the loader must complete with a [Uint8List] +/// containing the entire file content, +/// or with `null` if the file does not exist. +/// The loader may throw at its own discretion, for situations where +/// it determines that an error might be need user attention, +/// but it is always allowed to return `null`. +/// This function makes no attempt to catch such errors. +/// +/// If no [loader] is supplied, a default loader is used which +/// only accepts `file:`, `http:` and `https:` URIs, +/// and which uses the platform file system and HTTP requests to +/// fetch file content. The default loader never throws because +/// of an I/O issue, as long as the location URIs are valid. +/// As such, it does not distinguish between a file not existing, +/// and it being temporarily locked or unreachable. +/// +/// If [onError] is provided, the configuration file parsing will report errors +/// by calling that function, and then try to recover. +/// The returned package configuration is a *best effort* attempt to create +/// a valid configuration from the invalid configuration file. +/// If no [onError] is provided, errors are thrown immediately. +/// +/// If [minVersion] is set to something greater than its default, +/// any lower-version configuration files are ignored in the search. +/// +/// Returns `null` if no configuration file is found. +Future findPackageConfigUri(Uri location, + {bool recurse = true, + int minVersion = 1, + Future Function(Uri uri)? loader, + void Function(Object error)? onError}) { + if (minVersion > PackageConfig.maxVersion) { + throw ArgumentError.value(minVersion, 'minVersion', + 'Maximum known version is ${PackageConfig.maxVersion}'); + } + return discover.findPackageConfigUri( + location, minVersion, loader, onError ?? throwError, recurse); +} + +/// Writes a package configuration to the provided directory. +/// +/// Writes `.dart_tool/package_config.json` relative to [directory]. +/// If the `.dart_tool/` directory does not exist, it is created. +/// If it cannot be created, this operation fails. +/// +/// Also writes a `.packages` file in [directory]. +/// This will stop happening eventually as the `.packages` file becomes +/// discontinued. +/// A comment is generated if `[PackageConfig.extraData]` contains a +/// `"generator"` entry. +Future savePackageConfig( + PackageConfig configuration, Directory directory) => + writePackageConfigJsonFile(configuration, directory); diff --git a/pkgs/package_config/lib/package_config_types.dart b/pkgs/package_config/lib/package_config_types.dart new file mode 100644 index 000000000..825f7acec --- /dev/null +++ b/pkgs/package_config/lib/package_config_types.dart @@ -0,0 +1,17 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// A package configuration is a way to assign file paths to package URIs, +/// and vice-versa. +/// +/// {@canonicalFor package_config.InvalidLanguageVersion} +/// {@canonicalFor package_config.LanguageVersion} +/// {@canonicalFor package_config.Package} +/// {@canonicalFor package_config.PackageConfig} +/// {@canonicalFor errors.PackageConfigError} +library; + +export 'src/errors.dart' show PackageConfigError; +export 'src/package_config.dart' + show InvalidLanguageVersion, LanguageVersion, Package, PackageConfig; diff --git a/pkgs/package_config/lib/src/discovery.dart b/pkgs/package_config/lib/src/discovery.dart new file mode 100644 index 000000000..b67841099 --- /dev/null +++ b/pkgs/package_config/lib/src/discovery.dart @@ -0,0 +1,148 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:io'; +import 'dart:typed_data'; + +import 'errors.dart'; +import 'package_config_impl.dart'; +import 'package_config_io.dart'; +import 'package_config_json.dart'; +import 'packages_file.dart' as packages_file; +import 'util_io.dart' show defaultLoader, pathJoin; + +final Uri packageConfigJsonPath = Uri(path: '.dart_tool/package_config.json'); +final Uri dotPackagesPath = Uri(path: '.packages'); +final Uri currentPath = Uri(path: '.'); +final Uri parentPath = Uri(path: '..'); + +/// Discover the package configuration for a Dart script. +/// +/// The [baseDirectory] points to the directory of the Dart script. +/// A package resolution strategy is found by going through the following steps, +/// and stopping when something is found. +/// +/// * Check if a `.dart_tool/package_config.json` file exists in the directory. +/// * Check if a `.packages` file exists in the directory +/// (if `minVersion <= 1`). +/// * Repeat these checks for the parent directories until reaching the +/// root directory if [recursive] is true. +/// +/// If any of these tests succeed, a `PackageConfig` class is returned. +/// Returns `null` if no configuration was found. If a configuration +/// is needed, then the caller can supply [PackageConfig.empty]. +/// +/// If [minVersion] is greater than 1, `.packages` files are ignored. +/// If [minVersion] is greater than the version read from the +/// `package_config.json` file, it too is ignored. +Future findPackageConfig(Directory baseDirectory, + int minVersion, bool recursive, void Function(Object error) onError) async { + var directory = baseDirectory; + if (!directory.isAbsolute) directory = directory.absolute; + if (!await directory.exists()) { + return null; + } + do { + // Check for $cwd/.packages + var packageConfig = + await findPackageConfigInDirectory(directory, minVersion, onError); + if (packageConfig != null) return packageConfig; + if (!recursive) break; + // Check in parent directories. + var parentDirectory = directory.parent; + if (parentDirectory.path == directory.path) break; + directory = parentDirectory; + } while (true); + return null; +} + +/// Similar to [findPackageConfig] but based on a URI. +Future findPackageConfigUri( + Uri location, + int minVersion, + Future Function(Uri uri)? loader, + void Function(Object error) onError, + bool recursive) async { + if (location.isScheme('package')) { + onError(PackageConfigArgumentError( + location, 'location', 'Must not be a package: URI')); + return null; + } + if (loader == null) { + if (location.isScheme('file')) { + return findPackageConfig( + Directory.fromUri(location.resolveUri(currentPath)), + minVersion, + recursive, + onError); + } + loader = defaultLoader; + } + if (!location.path.endsWith('/')) location = location.resolveUri(currentPath); + while (true) { + var file = location.resolveUri(packageConfigJsonPath); + var bytes = await loader(file); + if (bytes != null) { + var config = parsePackageConfigBytes(bytes, file, onError); + if (config.version >= minVersion) return config; + } + if (minVersion <= 1) { + file = location.resolveUri(dotPackagesPath); + bytes = await loader(file); + if (bytes != null) { + return packages_file.parse(bytes, file, onError); + } + } + if (!recursive) break; + var parent = location.resolveUri(parentPath); + if (parent == location) break; + location = parent; + } + return null; +} + +/// Finds a `.packages` or `.dart_tool/package_config.json` file in [directory]. +/// +/// Loads the file, if it is there, and returns the resulting [PackageConfig]. +/// Returns `null` if the file isn't there. +/// Reports a [FormatException] if a file is there but the content is not valid. +/// If the file exists, but fails to be read, the file system error is reported. +/// +/// If [onError] is supplied, parsing errors are reported using that, and +/// a best-effort attempt is made to return a package configuration. +/// This may be the empty package configuration. +/// +/// If [minVersion] is greater than 1, `.packages` files are ignored. +/// If [minVersion] is greater than the version read from the +/// `package_config.json` file, it too is ignored. +Future findPackageConfigInDirectory(Directory directory, + int minVersion, void Function(Object error) onError) async { + var packageConfigFile = await checkForPackageConfigJsonFile(directory); + if (packageConfigFile != null) { + var config = await readPackageConfigJsonFile(packageConfigFile, onError); + if (config.version < minVersion) return null; + return config; + } + if (minVersion <= 1) { + packageConfigFile = await checkForDotPackagesFile(directory); + if (packageConfigFile != null) { + return await readDotPackagesFile(packageConfigFile, onError); + } + } + return null; +} + +Future checkForPackageConfigJsonFile(Directory directory) async { + assert(directory.isAbsolute); + var file = + File(pathJoin(directory.path, '.dart_tool', 'package_config.json')); + if (await file.exists()) return file; + return null; +} + +Future checkForDotPackagesFile(Directory directory) async { + var file = File(pathJoin(directory.path, '.packages')); + if (await file.exists()) return file; + return null; +} diff --git a/pkgs/package_config/lib/src/errors.dart b/pkgs/package_config/lib/src/errors.dart new file mode 100644 index 000000000..a66fef7f3 --- /dev/null +++ b/pkgs/package_config/lib/src/errors.dart @@ -0,0 +1,34 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// General superclass of most errors and exceptions thrown by this package. +/// +/// Only covers errors thrown while parsing package configuration files. +/// Programming errors and I/O exceptions are not covered. +abstract class PackageConfigError { + PackageConfigError._(); +} + +class PackageConfigArgumentError extends ArgumentError + implements PackageConfigError { + PackageConfigArgumentError( + Object? super.value, String super.name, String super.message) + : super.value(); + + PackageConfigArgumentError.from(ArgumentError error) + : super.value(error.invalidValue, error.name, error.message); +} + +class PackageConfigFormatException extends FormatException + implements PackageConfigError { + PackageConfigFormatException(super.message, Object? super.source, + [super.offset]); + + PackageConfigFormatException.from(FormatException exception) + : super(exception.message, exception.source, exception.offset); +} + +/// The default `onError` handler. +// ignore: only_throw_errors +Never throwError(Object error) => throw error; diff --git a/pkgs/package_config/lib/src/package_config.dart b/pkgs/package_config/lib/src/package_config.dart new file mode 100644 index 000000000..155dfc539 --- /dev/null +++ b/pkgs/package_config/lib/src/package_config.dart @@ -0,0 +1,402 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:typed_data'; + +import 'errors.dart'; +import 'package_config_impl.dart'; +import 'package_config_json.dart'; + +/// A package configuration. +/// +/// Associates configuration data to packages and files in packages. +/// +/// More members may be added to this class in the future, +/// so classes outside of this package must not implement [PackageConfig] +/// or any subclass of it. +abstract class PackageConfig { + /// The largest configuration version currently recognized. + static const int maxVersion = 2; + + /// An empty package configuration. + /// + /// A package configuration with no available packages. + /// Is used as a default value where a package configuration + /// is expected, but none have been specified or found. + static const PackageConfig empty = SimplePackageConfig.empty(); + + /// Creates a package configuration with the provided available [packages]. + /// + /// The packages must be valid packages (valid package name, valid + /// absolute directory URIs, valid language version, if any), + /// and there must not be two packages with the same name. + /// + /// The package's root ([Package.root]) and package-root + /// ([Package.packageUriRoot]) paths must satisfy a number of constraints + /// We say that one path (which we know ends with a `/` character) + /// is inside another path, if the latter path is a prefix of the former path, + /// including the two paths being the same. + /// + /// * No package's root must be the same as another package's root. + /// * The package-root of a package must be inside the package's root. + /// * If one package's package-root is inside another package's root, + /// then the latter package's package root must not be inside the former + /// package's root. (No getting between a package and its package root!) + /// This also disallows a package's root being the same as another + /// package's package root. + /// + /// If supplied, the [extraData] will be available as the + /// [PackageConfig.extraData] of the created configuration. + /// + /// The version of the resulting configuration is always [maxVersion]. + factory PackageConfig(Iterable packages, {Object? extraData}) => + SimplePackageConfig(maxVersion, packages, extraData); + + /// Parses a package configuration file. + /// + /// The [bytes] must be an UTF-8 encoded JSON object + /// containing a valid package configuration. + /// + /// The [baseUri] is used as the base for resolving relative + /// URI references in the configuration file. If the configuration + /// has been read from a file, the [baseUri] can be the URI of that + /// file, or of the directory it occurs in. + /// + /// If [onError] is provided, errors found during parsing or building + /// the configuration are reported by calling [onError] instead of + /// throwing, and parser makes a *best effort* attempt to continue + /// despite the error. The input must still be valid JSON. + /// The result may be [PackageConfig.empty] if there is no way to + /// extract useful information from the bytes. + static PackageConfig parseBytes(Uint8List bytes, Uri baseUri, + {void Function(Object error)? onError}) => + parsePackageConfigBytes(bytes, baseUri, onError ?? throwError); + + /// Parses a package configuration file. + /// + /// The [configuration] must be a JSON object + /// containing a valid package configuration. + /// + /// The [baseUri] is used as the base for resolving relative + /// URI references in the configuration file. If the configuration + /// has been read from a file, the [baseUri] can be the URI of that + /// file, or of the directory it occurs in. + /// + /// If [onError] is provided, errors found during parsing or building + /// the configuration are reported by calling [onError] instead of + /// throwing, and parser makes a *best effort* attempt to continue + /// despite the error. The input must still be valid JSON. + /// The result may be [PackageConfig.empty] if there is no way to + /// extract useful information from the bytes. + static PackageConfig parseString(String configuration, Uri baseUri, + {void Function(Object error)? onError}) => + parsePackageConfigString(configuration, baseUri, onError ?? throwError); + + /// Parses the JSON data of a package configuration file. + /// + /// The [jsonData] must be a JSON-like Dart data structure, + /// like the one provided by parsing JSON text using `dart:convert`, + /// containing a valid package configuration. + /// + /// The [baseUri] is used as the base for resolving relative + /// URI references in the configuration file. If the configuration + /// has been read from a file, the [baseUri] can be the URI of that + /// file, or of the directory it occurs in. + /// + /// If [onError] is provided, errors found during parsing or building + /// the configuration are reported by calling [onError] instead of + /// throwing, and parser makes a *best effort* attempt to continue + /// despite the error. The input must still be valid JSON. + /// The result may be [PackageConfig.empty] if there is no way to + /// extract useful information from the bytes. + static PackageConfig parseJson(Object? jsonData, Uri baseUri, + {void Function(Object error)? onError}) => + parsePackageConfigJson(jsonData, baseUri, onError ?? throwError); + + /// Writes a configuration file for this configuration on [output]. + /// + /// If [baseUri] is provided, URI references in the generated file + /// will be made relative to [baseUri] where possible. + static void writeBytes(PackageConfig configuration, Sink output, + [Uri? baseUri]) { + writePackageConfigJsonUtf8(configuration, baseUri, output); + } + + /// Writes a configuration JSON text for this configuration on [output]. + /// + /// If [baseUri] is provided, URI references in the generated file + /// will be made relative to [baseUri] where possible. + static void writeString(PackageConfig configuration, StringSink output, + [Uri? baseUri]) { + writePackageConfigJsonString(configuration, baseUri, output); + } + + /// Converts a configuration to a JSON-like data structure. + /// + /// If [baseUri] is provided, URI references in the generated data + /// will be made relative to [baseUri] where possible. + static Map toJson(PackageConfig configuration, + [Uri? baseUri]) => + packageConfigToJson(configuration, baseUri); + + /// The configuration version number. + /// + /// Currently this is 1 or 2, where + /// * Version one is the `.packages` file format and + /// * Version two is the first `package_config.json` format. + /// + /// Instances of this class supports both, and the version + /// is only useful for detecting which kind of file the configuration + /// was read from. + int get version; + + /// All the available packages of this configuration. + /// + /// No two of these packages have the same name, + /// and no two [Package.root] directories overlap. + Iterable get packages; + + /// Look up a package by name. + /// + /// Returns the [Package] from [packages] with [packageName] as + /// [Package.name]. Returns `null` if the package is not available in the + /// current configuration. + Package? operator [](String packageName); + + /// Provides the associated package for a specific [file] (or directory). + /// + /// Returns a [Package] which contains the [file]'s path, if any. + /// That is, the [Package.root] directory is a parent directory + /// of the [file]'s location. + /// + /// Returns `null` if the file does not belong to any package. + Package? packageOf(Uri file); + + /// Resolves a `package:` URI to a non-package URI + /// + /// The [packageUri] must be a valid package URI. That means: + /// * A URI with `package` as scheme, + /// * with no authority part (`package://...`), + /// * with a path starting with a valid package name followed by a slash, and + /// * with no query or fragment part. + /// + /// Throws an [ArgumentError] (which also implements [PackageConfigError]) + /// if the package URI is not valid. + /// + /// Returns `null` if the package name of [packageUri] is not available + /// in this package configuration. + /// Returns the remaining path of the package URI resolved relative to the + /// [Package.packageUriRoot] of the corresponding package. + Uri? resolve(Uri packageUri); + + /// The package URI which resolves to [nonPackageUri]. + /// + /// The [nonPackageUri] must not have any query or fragment part, + /// and it must not have `package` as scheme. + /// Throws an [ArgumentError] (which also implements [PackageConfigError]) + /// if the non-package URI is not valid. + /// + /// Returns a package URI which [resolve] will convert to [nonPackageUri], + /// if any such URI exists. Returns `null` if no such package URI exists. + Uri? toPackageUri(Uri nonPackageUri); + + /// Extra data associated with the package configuration. + /// + /// The data may be in any format, depending on who introduced it. + /// The standard `package_config.json` file storage will only store + /// JSON-like list/map data structures. + Object? get extraData; +} + +/// Configuration data for a single package. +abstract class Package { + /// Creates a package with the provided properties. + /// + /// The [name] must be a valid package name. + /// The [root] must be an absolute directory URI, meaning an absolute URI + /// with no query or fragment path and a path starting and ending with `/`. + /// The [packageUriRoot], if provided, must be either an absolute + /// directory URI or a relative URI reference which is then resolved + /// relative to [root]. It must then also be a subdirectory of [root], + /// or the same directory, and must end with `/`. + /// If [languageVersion] is supplied, it must be a valid Dart language + /// version, which means two decimal integer literals separated by a `.`, + /// where the integer literals have no leading zeros unless they are + /// a single zero digit. + /// + /// The [relativeRoot] controls whether the [root] is written as + /// relative to the `package_config.json` file when the package + /// configuration is written to a file. It defaults to being relative. + /// + /// If [extraData] is supplied, it will be available as the + /// [Package.extraData] of the created package. + factory Package(String name, Uri root, + {Uri? packageUriRoot, + LanguageVersion? languageVersion, + Object? extraData, + bool relativeRoot = true}) => + SimplePackage.validate(name, root, packageUriRoot, languageVersion, + extraData, relativeRoot, throwError)!; + + /// The package-name of the package. + String get name; + + /// The location of the root of the package. + /// + /// Is always an absolute URI with no query or fragment parts, + /// and with a path ending in `/`. + /// + /// All files in the [root] directory are considered + /// part of the package for purposes where that that matters. + Uri get root; + + /// The root of the files available through `package:` URIs. + /// + /// A `package:` URI with [name] as the package name is + /// resolved relative to this location. + /// + /// Is always an absolute URI with no query or fragment part + /// with a path ending in `/`, + /// and with a location which is a subdirectory + /// of the [root], or the same as the [root]. + Uri get packageUriRoot; + + /// The default language version associated with this package. + /// + /// Each package may have a default language version associated, + /// which is the language version used to parse and compile + /// Dart files in the package. + /// A package version is defined by two non-negative numbers, + /// the *major* and *minor* version numbers. + /// + /// A package may have no language version associated with it + /// in the package configuration, in which case tools should + /// use a default behavior for the package. + LanguageVersion? get languageVersion; + + /// Extra data associated with the specific package. + /// + /// The data may be in any format, depending on who introduced it. + /// The standard `package_config.json` file storage will only store + /// JSON-like list/map data structures. + Object? get extraData; + + /// Whether the [root] URI should be written as relative. + /// + /// When the configuration is written to a `package_config.json` + /// file, the [root] URI can be either relative to the file + /// location or absolute, controller by this value. + bool get relativeRoot; +} + +/// A language version. +/// +/// A language version is represented by two non-negative integers, +/// the [major] and [minor] version numbers. +/// +/// If errors during parsing are handled using an `onError` handler, +/// then an *invalid* language version may be represented by an +/// [InvalidLanguageVersion] object. +abstract class LanguageVersion implements Comparable { + /// The maximal value allowed by [major] and [minor] values; + static const int maxValue = 0x7FFFFFFF; + factory LanguageVersion(int major, int minor) { + RangeError.checkValueInInterval(major, 0, maxValue, 'major'); + RangeError.checkValueInInterval(minor, 0, maxValue, 'major'); + return SimpleLanguageVersion(major, minor, null); + } + + /// Parses a language version string. + /// + /// A valid language version string has the form + /// + /// > *decimalNumber* `.` *decimalNumber* + /// + /// where a *decimalNumber* is a non-empty sequence of decimal digits + /// with no unnecessary leading zeros (the decimal number only starts + /// with a zero digit if that digit is the entire number). + /// No spaces are allowed in the string. + /// + /// If the [source] is valid then it is parsed into a valid + /// [LanguageVersion] object. + /// If not, then the [onError] is called with a [FormatException]. + /// If [onError] is not supplied, it defaults to throwing the exception. + /// If the call does not throw, then an [InvalidLanguageVersion] is returned + /// containing the original [source]. + static LanguageVersion parse(String source, + {void Function(Object error)? onError}) => + parseLanguageVersion(source, onError ?? throwError); + + /// The major language version. + /// + /// A non-negative integer less than 231. + /// + /// The value is negative for objects representing *invalid* language + /// versions ([InvalidLanguageVersion]). + int get major; + + /// The minor language version. + /// + /// A non-negative integer less than 231. + /// + /// The value is negative for objects representing *invalid* language + /// versions ([InvalidLanguageVersion]). + int get minor; + + /// Compares language versions. + /// + /// Two language versions are considered equal if they have the + /// same major and minor version numbers. + /// + /// A language version is greater then another if the former's major version + /// is greater than the latter's major version, or if they have + /// the same major version and the former's minor version is greater than + /// the latter's. + @override + int compareTo(LanguageVersion other); + + /// Valid language versions with the same [major] and [minor] values are + /// equal. + /// + /// Invalid language versions ([InvalidLanguageVersion]) are not equal to + /// any other object. + @override + bool operator ==(Object other); + + @override + int get hashCode; + + /// A string representation of the language version. + /// + /// A valid language version is represented as + /// `"${version.major}.${version.minor}"`. + @override + String toString(); +} + +/// An *invalid* language version. +/// +/// Stored in a [Package] when the original language version string +/// was invalid and a `onError` handler was passed to the parser +/// which did not throw on an error. +abstract class InvalidLanguageVersion implements LanguageVersion { + /// The value -1 for an invalid language version. + @override + int get major; + + /// The value -1 for an invalid language version. + @override + int get minor; + + /// An invalid language version is only equal to itself. + @override + bool operator ==(Object other); + + @override + int get hashCode; + + /// The original invalid version string. + @override + String toString(); +} diff --git a/pkgs/package_config/lib/src/package_config_impl.dart b/pkgs/package_config/lib/src/package_config_impl.dart new file mode 100644 index 000000000..865e99a8e --- /dev/null +++ b/pkgs/package_config/lib/src/package_config_impl.dart @@ -0,0 +1,568 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'errors.dart'; +import 'package_config.dart'; +import 'util.dart'; + +export 'package_config.dart'; + +const bool _disallowPackagesInsidePackageUriRoot = false; + +// Implementations of the main data types exposed by the API of this package. + +class SimplePackageConfig implements PackageConfig { + @override + final int version; + final Map _packages; + final PackageTree _packageTree; + @override + final Object? extraData; + + factory SimplePackageConfig(int version, Iterable packages, + [Object? extraData, void Function(Object error)? onError]) { + onError ??= throwError; + var validVersion = _validateVersion(version, onError); + var sortedPackages = [...packages]..sort(_compareRoot); + var packageTree = _validatePackages(packages, sortedPackages, onError); + return SimplePackageConfig._(validVersion, packageTree, + {for (var p in packageTree.allPackages) p.name: p}, extraData); + } + + SimplePackageConfig._( + this.version, this._packageTree, this._packages, this.extraData); + + /// Creates empty configuration. + /// + /// The empty configuration can be used in cases where no configuration is + /// found, but code expects a non-null configuration. + /// + /// The version number is [PackageConfig.maxVersion] to avoid + /// minimum-version filters discarding the configuration. + const SimplePackageConfig.empty() + : version = PackageConfig.maxVersion, + _packageTree = const EmptyPackageTree(), + _packages = const {}, + extraData = null; + + static int _validateVersion( + int version, void Function(Object error) onError) { + if (version < 0 || version > PackageConfig.maxVersion) { + onError(PackageConfigArgumentError(version, 'version', + 'Must be in the range 1 to ${PackageConfig.maxVersion}')); + return 2; // The minimal version supporting a SimplePackageConfig. + } + return version; + } + + static PackageTree _validatePackages(Iterable originalPackages, + List packages, void Function(Object error) onError) { + var packageNames = {}; + var tree = TriePackageTree(); + for (var originalPackage in packages) { + SimplePackage? newPackage; + if (originalPackage is! SimplePackage) { + // SimplePackage validates these properties. + newPackage = SimplePackage.validate( + originalPackage.name, + originalPackage.root, + originalPackage.packageUriRoot, + originalPackage.languageVersion, + originalPackage.extraData, + originalPackage.relativeRoot, (error) { + if (error is PackageConfigArgumentError) { + onError(PackageConfigArgumentError(packages, 'packages', + 'Package ${newPackage!.name}: ${error.message}')); + } else { + onError(error); + } + }); + if (newPackage == null) continue; + } else { + newPackage = originalPackage; + } + var name = newPackage.name; + if (packageNames.contains(name)) { + onError(PackageConfigArgumentError( + name, 'packages', "Duplicate package name '$name'")); + continue; + } + packageNames.add(name); + tree.add(newPackage, (error) { + if (error is ConflictException) { + // There is a conflict with an existing package. + var existingPackage = error.existingPackage; + switch (error.conflictType) { + case ConflictType.sameRoots: + onError(PackageConfigArgumentError( + originalPackages, + 'packages', + 'Packages ${newPackage!.name} and ${existingPackage.name} ' + 'have the same root directory: ${newPackage.root}.\n')); + break; + case ConflictType.interleaving: + // The new package is inside the package URI root of the existing + // package. + onError(PackageConfigArgumentError( + originalPackages, + 'packages', + 'Package ${newPackage!.name} is inside the root of ' + 'package ${existingPackage.name}, and the package root ' + 'of ${existingPackage.name} is inside the root of ' + '${newPackage.name}.\n' + '${existingPackage.name} package root: ' + '${existingPackage.packageUriRoot}\n' + '${newPackage.name} root: ${newPackage.root}\n')); + break; + case ConflictType.insidePackageRoot: + onError(PackageConfigArgumentError( + originalPackages, + 'packages', + 'Package ${newPackage!.name} is inside the package root of ' + 'package ${existingPackage.name}.\n' + '${existingPackage.name} package root: ' + '${existingPackage.packageUriRoot}\n' + '${newPackage.name} root: ${newPackage.root}\n')); + break; + } + } else { + // Any other error. + onError(error); + } + }); + } + return tree; + } + + @override + Iterable get packages => _packages.values; + + @override + Package? operator [](String packageName) => _packages[packageName]; + + @override + Package? packageOf(Uri file) => _packageTree.packageOf(file); + + @override + Uri? resolve(Uri packageUri) { + var packageName = checkValidPackageUri(packageUri, 'packageUri'); + return _packages[packageName]?.packageUriRoot.resolveUri( + Uri(path: packageUri.path.substring(packageName.length + 1))); + } + + @override + Uri? toPackageUri(Uri nonPackageUri) { + if (nonPackageUri.isScheme('package')) { + throw PackageConfigArgumentError( + nonPackageUri, 'nonPackageUri', 'Must not be a package URI'); + } + if (nonPackageUri.hasQuery || nonPackageUri.hasFragment) { + throw PackageConfigArgumentError(nonPackageUri, 'nonPackageUri', + 'Must not have query or fragment part'); + } + // Find package that file belongs to. + var package = _packageTree.packageOf(nonPackageUri); + if (package == null) return null; + // Check if it is inside the package URI root. + var path = nonPackageUri.toString(); + var root = package.packageUriRoot.toString(); + if (_beginsWith(package.root.toString().length, root, path)) { + var rest = path.substring(root.length); + return Uri(scheme: 'package', path: '${package.name}/$rest'); + } + return null; + } +} + +/// Configuration data for a single package. +class SimplePackage implements Package { + @override + final String name; + @override + final Uri root; + @override + final Uri packageUriRoot; + @override + final LanguageVersion? languageVersion; + @override + final Object? extraData; + @override + final bool relativeRoot; + + SimplePackage._(this.name, this.root, this.packageUriRoot, + this.languageVersion, this.extraData, this.relativeRoot); + + /// Creates a [SimplePackage] with the provided content. + /// + /// The provided arguments must be valid. + /// + /// If the arguments are invalid then the error is reported by + /// calling [onError], then the erroneous entry is ignored. + /// + /// If [onError] is provided, the user is expected to be able to handle + /// errors themselves. An invalid [languageVersion] string + /// will be replaced with the string `"invalid"`. This allows + /// users to detect the difference between an absent version and + /// an invalid one. + /// + /// Returns `null` if the input is invalid and an approximately valid package + /// cannot be salvaged from the input. + static SimplePackage? validate( + String name, + Uri root, + Uri? packageUriRoot, + LanguageVersion? languageVersion, + Object? extraData, + bool relativeRoot, + void Function(Object error) onError) { + var fatalError = false; + var invalidIndex = checkPackageName(name); + if (invalidIndex >= 0) { + onError(PackageConfigFormatException( + 'Not a valid package name', name, invalidIndex)); + fatalError = true; + } + if (root.isScheme('package')) { + onError(PackageConfigArgumentError( + '$root', 'root', 'Must not be a package URI')); + fatalError = true; + } else if (!isAbsoluteDirectoryUri(root)) { + onError(PackageConfigArgumentError( + '$root', + 'root', + 'In package $name: Not an absolute URI with no query or fragment ' + 'with a path ending in /')); + // Try to recover. If the URI has a scheme, + // then ensure that the path ends with `/`. + if (!root.hasScheme) { + fatalError = true; + } else if (!root.path.endsWith('/')) { + root = root.replace(path: '${root.path}/'); + } + } + if (packageUriRoot == null) { + packageUriRoot = root; + } else if (!fatalError) { + packageUriRoot = root.resolveUri(packageUriRoot); + if (!isAbsoluteDirectoryUri(packageUriRoot)) { + onError(PackageConfigArgumentError( + packageUriRoot, + 'packageUriRoot', + 'In package $name: Not an absolute URI with no query or fragment ' + 'with a path ending in /')); + packageUriRoot = root; + } else if (!isUriPrefix(root, packageUriRoot)) { + onError(PackageConfigArgumentError(packageUriRoot, 'packageUriRoot', + 'The package URI root is not below the package root')); + packageUriRoot = root; + } + } + if (fatalError) return null; + return SimplePackage._( + name, root, packageUriRoot, languageVersion, extraData, relativeRoot); + } +} + +/// Checks whether [source] is a valid Dart language version string. +/// +/// The format is (as RegExp) `^(0|[1-9]\d+)\.(0|[1-9]\d+)$`. +/// +/// Reports a format exception on [onError] if not, or if the numbers +/// are too large (at most 32-bit signed integers). +LanguageVersion parseLanguageVersion( + String? source, void Function(Object error) onError) { + var index = 0; + // Reads a positive decimal numeral. Returns the value of the numeral, + // or a negative number in case of an error. + // Starts at [index] and increments the index to the position after + // the numeral. + // It is an error if the numeral value is greater than 0x7FFFFFFFF. + // It is a recoverable error if the numeral starts with leading zeros. + int readNumeral() { + const maxValue = 0x7FFFFFFF; + if (index == source!.length) { + onError(PackageConfigFormatException('Missing number', source, index)); + return -1; + } + var start = index; + + var char = source.codeUnitAt(index); + var digit = char ^ 0x30; + if (digit > 9) { + onError(PackageConfigFormatException('Missing number', source, index)); + return -1; + } + var firstDigit = digit; + var value = 0; + do { + value = value * 10 + digit; + if (value > maxValue) { + onError( + PackageConfigFormatException('Number too large', source, start)); + return -1; + } + index++; + if (index == source.length) break; + char = source.codeUnitAt(index); + digit = char ^ 0x30; + } while (digit <= 9); + if (firstDigit == 0 && index > start + 1) { + onError(PackageConfigFormatException( + 'Leading zero not allowed', source, start)); + } + return value; + } + + var major = readNumeral(); + if (major < 0) { + return SimpleInvalidLanguageVersion(source); + } + if (index == source!.length || source.codeUnitAt(index) != $dot) { + onError(PackageConfigFormatException("Missing '.'", source, index)); + return SimpleInvalidLanguageVersion(source); + } + index++; + var minor = readNumeral(); + if (minor < 0) { + return SimpleInvalidLanguageVersion(source); + } + if (index != source.length) { + onError(PackageConfigFormatException( + 'Unexpected trailing character', source, index)); + return SimpleInvalidLanguageVersion(source); + } + return SimpleLanguageVersion(major, minor, source); +} + +abstract class _SimpleLanguageVersionBase implements LanguageVersion { + @override + int compareTo(LanguageVersion other) { + var result = major.compareTo(other.major); + if (result != 0) return result; + return minor.compareTo(other.minor); + } +} + +class SimpleLanguageVersion extends _SimpleLanguageVersionBase { + @override + final int major; + @override + final int minor; + String? _source; + SimpleLanguageVersion(this.major, this.minor, this._source); + + @override + bool operator ==(Object other) => + other is LanguageVersion && major == other.major && minor == other.minor; + + @override + int get hashCode => (major * 17 ^ minor * 37) & 0x3FFFFFFF; + + @override + String toString() => _source ??= '$major.$minor'; +} + +class SimpleInvalidLanguageVersion extends _SimpleLanguageVersionBase + implements InvalidLanguageVersion { + final String? _source; + SimpleInvalidLanguageVersion(this._source); + @override + int get major => -1; + @override + int get minor => -1; + + @override + String toString() => _source!; +} + +abstract class PackageTree { + Iterable get allPackages; + SimplePackage? packageOf(Uri file); +} + +class _PackageTrieNode { + SimplePackage? package; + + /// Indexed by path segment. + Map map = {}; +} + +/// Packages of a package configuration ordered by root path. +/// +/// A package has a root path and a package root path, where the latter +/// contains the files exposed by `package:` URIs. +/// +/// A package is said to be inside another package if the root path URI of +/// the latter is a prefix of the root path URI of the former. +/// +/// No two packages of a package may have the same root path. +/// The package root path of a package must not be inside another package's +/// root path. +/// Entire other packages are allowed inside a package's root. +class TriePackageTree implements PackageTree { + /// Indexed by URI scheme. + final Map _map = {}; + + /// A list of all packages. + final List _packages = []; + + @override + Iterable get allPackages sync* { + for (var package in _packages) { + yield package; + } + } + + bool _checkConflict(_PackageTrieNode node, SimplePackage newPackage, + void Function(Object error) onError) { + var existingPackage = node.package; + if (existingPackage != null) { + // Trying to add package that is inside the existing package. + // 1) If it's an exact match it's not allowed (i.e. the roots can't be + // the same). + if (newPackage.root.path.length == existingPackage.root.path.length) { + onError(ConflictException( + newPackage, existingPackage, ConflictType.sameRoots)); + return true; + } + // 2) The existing package has a packageUriRoot thats inside the + // root of the new package. + if (_beginsWith(0, newPackage.root.toString(), + existingPackage.packageUriRoot.toString())) { + onError(ConflictException( + newPackage, existingPackage, ConflictType.interleaving)); + return true; + } + + // For internal reasons we allow this (for now). One should still never do + // it though. + // 3) The new package is inside the packageUriRoot of existing package. + if (_disallowPackagesInsidePackageUriRoot) { + if (_beginsWith(0, existingPackage.packageUriRoot.toString(), + newPackage.root.toString())) { + onError(ConflictException( + newPackage, existingPackage, ConflictType.insidePackageRoot)); + return true; + } + } + } + return false; + } + + /// Tries to add `newPackage` to the tree. + /// + /// Reports a [ConflictException] if the added package conflicts with an + /// existing package. + /// It conflicts if its root or package root is the same as an existing + /// package's root or package root, is between the two, or if it's inside the + /// package root of an existing package. + /// + /// If a conflict is detected between [newPackage] and a previous package, + /// then [onError] is called with a [ConflictException] object + /// and the [newPackage] is not added to the tree. + /// + /// The packages are added in order of their root path. + void add(SimplePackage newPackage, void Function(Object error) onError) { + var root = newPackage.root; + var node = _map[root.scheme] ??= _PackageTrieNode(); + if (_checkConflict(node, newPackage, onError)) return; + var segments = root.pathSegments; + // Notice that we're skipping the last segment as it's always the empty + // string because roots are directories. + for (var i = 0; i < segments.length - 1; i++) { + var path = segments[i]; + node = node.map[path] ??= _PackageTrieNode(); + if (_checkConflict(node, newPackage, onError)) return; + } + node.package = newPackage; + _packages.add(newPackage); + } + + bool _isMatch( + String path, _PackageTrieNode node, List potential) { + var currentPackage = node.package; + if (currentPackage != null) { + var currentPackageRootLength = currentPackage.root.toString().length; + if (path.length == currentPackageRootLength) return true; + var currentPackageUriRoot = currentPackage.packageUriRoot.toString(); + // Is [file] inside the package root of [currentPackage]? + if (currentPackageUriRoot.length == currentPackageRootLength || + _beginsWith(currentPackageRootLength, currentPackageUriRoot, path)) { + return true; + } + potential.add(currentPackage); + } + return false; + } + + @override + SimplePackage? packageOf(Uri file) { + var currentTrieNode = _map[file.scheme]; + if (currentTrieNode == null) return null; + var path = file.toString(); + var potential = []; + if (_isMatch(path, currentTrieNode, potential)) { + return currentTrieNode.package; + } + var segments = file.pathSegments; + + for (var i = 0; i < segments.length - 1; i++) { + var segment = segments[i]; + currentTrieNode = currentTrieNode!.map[segment]; + if (currentTrieNode == null) break; + if (_isMatch(path, currentTrieNode, potential)) { + return currentTrieNode.package; + } + } + if (potential.isEmpty) return null; + return potential.last; + } +} + +class EmptyPackageTree implements PackageTree { + const EmptyPackageTree(); + + @override + Iterable get allPackages => const Iterable.empty(); + + @override + SimplePackage? packageOf(Uri file) => null; +} + +/// Checks whether [longerPath] begins with [parentPath]. +/// +/// Skips checking the [start] first characters which are assumed to +/// already have been matched. +bool _beginsWith(int start, String parentPath, String longerPath) { + if (longerPath.length < parentPath.length) return false; + for (var i = start; i < parentPath.length; i++) { + if (longerPath.codeUnitAt(i) != parentPath.codeUnitAt(i)) return false; + } + return true; +} + +enum ConflictType { sameRoots, interleaving, insidePackageRoot } + +/// Conflict between packages added to the same configuration. +/// +/// The [package] conflicts with [existingPackage] if it has +/// the same root path or the package URI root path +/// of [existingPackage] is inside the root path of [package]. +class ConflictException { + /// The existing package that [package] conflicts with. + final SimplePackage existingPackage; + + /// The package that could not be added without a conflict. + final SimplePackage package; + + /// Whether the conflict is with the package URI root of [existingPackage]. + final ConflictType conflictType; + + /// Creates a root conflict between [package] and [existingPackage]. + ConflictException(this.package, this.existingPackage, this.conflictType); +} + +/// Used for sorting packages by root path. +int _compareRoot(Package p1, Package p2) => + p1.root.toString().compareTo(p2.root.toString()); diff --git a/pkgs/package_config/lib/src/package_config_io.dart b/pkgs/package_config/lib/src/package_config_io.dart new file mode 100644 index 000000000..8c5773b2b --- /dev/null +++ b/pkgs/package_config/lib/src/package_config_io.dart @@ -0,0 +1,166 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// dart:io dependent functionality for reading and writing configuration files. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'errors.dart'; +import 'package_config_impl.dart'; +import 'package_config_json.dart'; +import 'packages_file.dart' as packages_file; +import 'util.dart'; +import 'util_io.dart'; + +/// Name of directory where Dart tools store their configuration. +/// +/// Directory is created in the package root directory. +const dartToolDirName = '.dart_tool'; + +/// Name of file containing new package configuration data. +/// +/// File is stored in the dart tool directory. +const packageConfigFileName = 'package_config.json'; + +/// Name of file containing legacy package configuration data. +/// +/// File is stored in the package root directory. +const packagesFileName = '.packages'; + +/// Reads a package configuration file. +/// +/// Detects whether the [file] is a version one `.packages` file or +/// a version two `package_config.json` file. +/// +/// If the [file] is a `.packages` file and [preferNewest] is true, +/// first checks whether there is an adjacent `.dart_tool/package_config.json` +/// file, and if so, reads that instead. +/// If [preferNewest] is false, the specified file is loaded even if it is +/// a `.packages` file and there is an available `package_config.json` file. +/// +/// The file must exist and be a normal file. +Future readAnyConfigFile( + File file, bool preferNewest, void Function(Object error) onError) async { + if (preferNewest && fileName(file.path) == packagesFileName) { + var alternateFile = File( + pathJoin(dirName(file.path), dartToolDirName, packageConfigFileName)); + if (alternateFile.existsSync()) { + return await readPackageConfigJsonFile(alternateFile, onError); + } + } + Uint8List bytes; + try { + bytes = await file.readAsBytes(); + } catch (e) { + onError(e); + return const SimplePackageConfig.empty(); + } + return parseAnyConfigFile(bytes, file.uri, onError); +} + +/// Like [readAnyConfigFile] but uses a URI and an optional loader. +Future readAnyConfigFileUri( + Uri file, + Future Function(Uri uri)? loader, + void Function(Object error) onError, + bool preferNewest) async { + if (file.isScheme('package')) { + throw PackageConfigArgumentError( + file, 'file', 'Must not be a package: URI'); + } + if (loader == null) { + if (file.isScheme('file')) { + return await readAnyConfigFile(File.fromUri(file), preferNewest, onError); + } + loader = defaultLoader; + } + if (preferNewest && file.pathSegments.last == packagesFileName) { + var alternateFile = file.resolve('$dartToolDirName/$packageConfigFileName'); + Uint8List? bytes; + try { + bytes = await loader(alternateFile); + } catch (e) { + onError(e); + return const SimplePackageConfig.empty(); + } + if (bytes != null) { + return parsePackageConfigBytes(bytes, alternateFile, onError); + } + } + Uint8List? bytes; + try { + bytes = await loader(file); + } catch (e) { + onError(e); + return const SimplePackageConfig.empty(); + } + if (bytes == null) { + onError(PackageConfigArgumentError( + file.toString(), 'file', 'File cannot be read')); + return const SimplePackageConfig.empty(); + } + return parseAnyConfigFile(bytes, file, onError); +} + +/// Parses a `.packages` or `package_config.json` file's contents. +/// +/// Assumes it's a JSON file if the first non-whitespace character +/// is `{`, otherwise assumes it's a `.packages` file. +PackageConfig parseAnyConfigFile( + Uint8List bytes, Uri file, void Function(Object error) onError) { + var firstChar = firstNonWhitespaceChar(bytes); + if (firstChar != $lbrace) { + // Definitely not a JSON object, probably a .packages. + return packages_file.parse(bytes, file, onError); + } + return parsePackageConfigBytes(bytes, file, onError); +} + +Future readPackageConfigJsonFile( + File file, void Function(Object error) onError) async { + Uint8List bytes; + try { + bytes = await file.readAsBytes(); + } catch (error) { + onError(error); + return const SimplePackageConfig.empty(); + } + return parsePackageConfigBytes(bytes, file.uri, onError); +} + +Future readDotPackagesFile( + File file, void Function(Object error) onError) async { + Uint8List bytes; + try { + bytes = await file.readAsBytes(); + } catch (error) { + onError(error); + return const SimplePackageConfig.empty(); + } + return packages_file.parse(bytes, file.uri, onError); +} + +Future writePackageConfigJsonFile( + PackageConfig config, Directory targetDirectory) async { + // Write .dart_tool/package_config.json first. + var dartToolDir = Directory(pathJoin(targetDirectory.path, dartToolDirName)); + await dartToolDir.create(recursive: true); + var file = File(pathJoin(dartToolDir.path, packageConfigFileName)); + var baseUri = file.uri; + + var sink = file.openWrite(encoding: utf8); + writePackageConfigJsonUtf8(config, baseUri, sink); + var doneJson = sink.close(); + + // Write .packages too. + file = File(pathJoin(targetDirectory.path, packagesFileName)); + baseUri = file.uri; + sink = file.openWrite(encoding: utf8); + writeDotPackages(config, baseUri, sink); + var donePackages = sink.close(); + + await Future.wait([doneJson, donePackages]); +} diff --git a/pkgs/package_config/lib/src/package_config_json.dart b/pkgs/package_config/lib/src/package_config_json.dart new file mode 100644 index 000000000..65560a0f0 --- /dev/null +++ b/pkgs/package_config/lib/src/package_config_json.dart @@ -0,0 +1,321 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// Parsing and serialization of package configurations. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'errors.dart'; +import 'package_config_impl.dart'; +import 'packages_file.dart' as packages_file; +import 'util.dart'; + +const String _configVersionKey = 'configVersion'; +const String _packagesKey = 'packages'; +const List _topNames = [_configVersionKey, _packagesKey]; +const String _nameKey = 'name'; +const String _rootUriKey = 'rootUri'; +const String _packageUriKey = 'packageUri'; +const String _languageVersionKey = 'languageVersion'; +const List _packageNames = [ + _nameKey, + _rootUriKey, + _packageUriKey, + _languageVersionKey +]; + +const String _generatedKey = 'generated'; +const String _generatorKey = 'generator'; +const String _generatorVersionKey = 'generatorVersion'; + +final _jsonUtf8Decoder = json.fuse(utf8).decoder; + +PackageConfig parsePackageConfigBytes( + Uint8List bytes, Uri file, void Function(Object error) onError) { + // TODO(lrn): Make this simpler. Maybe parse directly from bytes. + Object? jsonObject; + try { + jsonObject = _jsonUtf8Decoder.convert(bytes); + } on FormatException catch (e) { + onError(PackageConfigFormatException.from(e)); + return const SimplePackageConfig.empty(); + } + return parsePackageConfigJson(jsonObject, file, onError); +} + +PackageConfig parsePackageConfigString( + String source, Uri file, void Function(Object error) onError) { + Object? jsonObject; + try { + jsonObject = jsonDecode(source); + } on FormatException catch (e) { + onError(PackageConfigFormatException.from(e)); + return const SimplePackageConfig.empty(); + } + return parsePackageConfigJson(jsonObject, file, onError); +} + +/// Creates a [PackageConfig] from a parsed JSON-like object structure. +/// +/// The [json] argument must be a JSON object (`Map`) +/// containing a `"configVersion"` entry with an integer value in the range +/// 1 to [PackageConfig.maxVersion], +/// and with a `"packages"` entry which is a JSON array (`List`) +/// containing JSON objects which each has the following properties: +/// +/// * `"name"`: The package name as a string. +/// * `"rootUri"`: The root of the package as a URI stored as a string. +/// * `"packageUri"`: Optionally the root of for `package:` URI resolution +/// for the package, as a relative URI below the root URI +/// stored as a string. +/// * `"languageVersion"`: Optionally a language version string which is a +/// an integer numeral, a decimal point (`.`) and another integer numeral, +/// where the integer numeral cannot have a sign, and can only have a +/// leading zero if the entire numeral is a single zero. +/// +/// The [baseLocation] is used as base URI to resolve the "rootUri" +/// URI reference string. +PackageConfig parsePackageConfigJson( + Object? json, Uri baseLocation, void Function(Object error) onError) { + if (!baseLocation.hasScheme || baseLocation.isScheme('package')) { + throw PackageConfigArgumentError(baseLocation.toString(), 'baseLocation', + 'Must be an absolute non-package: URI'); + } + + if (!baseLocation.path.endsWith('/')) { + baseLocation = baseLocation.resolveUri(Uri(path: '.')); + } + + String typeName() { + if (0 is T) return 'int'; + if ('' is T) return 'string'; + if (const [] is T) return 'array'; + return 'object'; + } + + T? checkType(Object? value, String name, [String? packageName]) { + if (value is T) return value; + // The only types we are called with are [int], [String], [List] + // and Map. Recognize which to give a better error message. + var message = + "$name${packageName != null ? " of package $packageName" : ""}" + ' is not a JSON ${typeName()}'; + onError(PackageConfigFormatException(message, value)); + return null; + } + + Package? parsePackage(Map entry) { + String? name; + String? rootUri; + String? packageUri; + String? languageVersion; + Map? extraData; + var hasName = false; + var hasRoot = false; + var hasVersion = false; + entry.forEach((key, value) { + switch (key) { + case _nameKey: + hasName = true; + name = checkType(value, _nameKey); + break; + case _rootUriKey: + hasRoot = true; + rootUri = checkType(value, _rootUriKey, name); + break; + case _packageUriKey: + packageUri = checkType(value, _packageUriKey, name); + break; + case _languageVersionKey: + hasVersion = true; + languageVersion = checkType(value, _languageVersionKey, name); + break; + default: + (extraData ??= {})[key] = value; + break; + } + }); + if (!hasName) { + onError(PackageConfigFormatException('Missing name entry', entry)); + } + if (!hasRoot) { + onError(PackageConfigFormatException('Missing rootUri entry', entry)); + } + if (name == null || rootUri == null) return null; + var parsedRootUri = Uri.parse(rootUri!); + var relativeRoot = !hasAbsolutePath(parsedRootUri); + var root = baseLocation.resolveUri(parsedRootUri); + if (!root.path.endsWith('/')) root = root.replace(path: '${root.path}/'); + var packageRoot = root; + if (packageUri != null) packageRoot = root.resolve(packageUri!); + if (!packageRoot.path.endsWith('/')) { + packageRoot = packageRoot.replace(path: '${packageRoot.path}/'); + } + + LanguageVersion? version; + if (languageVersion != null) { + version = parseLanguageVersion(languageVersion, onError); + } else if (hasVersion) { + version = SimpleInvalidLanguageVersion('invalid'); + } + + return SimplePackage.validate( + name!, root, packageRoot, version, extraData, relativeRoot, (error) { + if (error is ArgumentError) { + onError( + PackageConfigFormatException( + error.message.toString(), error.invalidValue), + ); + } else { + onError(error); + } + }); + } + + var map = checkType>(json, 'value'); + if (map == null) return const SimplePackageConfig.empty(); + Map? extraData; + List? packageList; + int? configVersion; + map.forEach((key, value) { + switch (key) { + case _configVersionKey: + configVersion = checkType(value, _configVersionKey) ?? 2; + break; + case _packagesKey: + var packageArray = checkType>(value, _packagesKey) ?? []; + var packages = []; + for (var package in packageArray) { + var packageMap = + checkType>(package, 'package entry'); + if (packageMap != null) { + var entry = parsePackage(packageMap); + if (entry != null) { + packages.add(entry); + } + } + } + packageList = packages; + break; + default: + (extraData ??= {})[key] = value; + break; + } + }); + if (configVersion == null) { + onError(PackageConfigFormatException('Missing configVersion entry', json)); + configVersion = 2; + } + if (packageList == null) { + onError(PackageConfigFormatException('Missing packages list', json)); + packageList = []; + } + return SimplePackageConfig(configVersion!, packageList!, extraData, (error) { + if (error is ArgumentError) { + onError( + PackageConfigFormatException( + error.message.toString(), error.invalidValue), + ); + } else { + onError(error); + } + }); +} + +final _jsonUtf8Encoder = JsonUtf8Encoder(' '); + +void writePackageConfigJsonUtf8( + PackageConfig config, Uri? baseUri, Sink> output) { + // Can be optimized. + var data = packageConfigToJson(config, baseUri); + output.add(_jsonUtf8Encoder.convert(data) as Uint8List); +} + +void writePackageConfigJsonString( + PackageConfig config, Uri? baseUri, StringSink output) { + // Can be optimized. + var data = packageConfigToJson(config, baseUri); + output.write(const JsonEncoder.withIndent(' ').convert(data)); +} + +Map packageConfigToJson(PackageConfig config, Uri? baseUri) => + { + ...?_extractExtraData(config.extraData, _topNames), + _configVersionKey: PackageConfig.maxVersion, + _packagesKey: [ + for (var package in config.packages) + { + _nameKey: package.name, + _rootUriKey: trailingSlash((package.relativeRoot + ? relativizeUri(package.root, baseUri) + : package.root) + .toString()), + if (package.root != package.packageUriRoot) + _packageUriKey: trailingSlash( + relativizeUri(package.packageUriRoot, package.root) + .toString()), + if (package.languageVersion != null && + package.languageVersion is! InvalidLanguageVersion) + _languageVersionKey: package.languageVersion.toString(), + ...?_extractExtraData(package.extraData, _packageNames), + } + ], + }; + +void writeDotPackages(PackageConfig config, Uri baseUri, StringSink output) { + var extraData = config.extraData; + // Write .packages too. + String? comment; + if (extraData is Map) { + var generator = extraData[_generatorKey]; + if (generator is String) { + var generated = extraData[_generatedKey]; + var generatorVersion = extraData[_generatorVersionKey]; + comment = 'Generated by $generator' + "${generatorVersion is String ? " $generatorVersion" : ""}" + "${generated is String ? " on $generated" : ""}."; + } + } + packages_file.write(output, config, baseUri: baseUri, comment: comment); +} + +/// If "extraData" is a JSON map, then return it, otherwise return null. +/// +/// If the value contains any of the [reservedNames] for the current context, +/// entries with that name in the extra data are dropped. +Map? _extractExtraData( + Object? data, Iterable reservedNames) { + if (data is Map) { + if (data.isEmpty) return null; + for (var name in reservedNames) { + if (data.containsKey(name)) { + var filteredData = { + for (var key in data.keys) + if (!reservedNames.contains(key)) key: data[key] + }; + if (filteredData.isEmpty) return null; + for (var value in filteredData.values) { + if (!_validateJson(value)) return null; + } + return filteredData; + } + } + return data; + } + return null; +} + +/// Checks that the object is a valid JSON-like data structure. +bool _validateJson(Object? object) { + if (object == null || true == object || false == object) return true; + if (object is num || object is String) return true; + if (object is List) { + return object.every(_validateJson); + } + if (object is Map) { + return object.values.every(_validateJson); + } + return false; +} diff --git a/pkgs/package_config/lib/src/packages_file.dart b/pkgs/package_config/lib/src/packages_file.dart new file mode 100644 index 000000000..bf68f2c88 --- /dev/null +++ b/pkgs/package_config/lib/src/packages_file.dart @@ -0,0 +1,193 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'errors.dart'; +import 'package_config_impl.dart'; +import 'util.dart'; + +/// The language version prior to the release of language versioning. +/// +/// This is the default language version used by all packages from a +/// `.packages` file. +final LanguageVersion _languageVersion = LanguageVersion(2, 7); + +/// Parses a `.packages` file into a [PackageConfig]. +/// +/// The [source] is the byte content of a `.packages` file, assumed to be +/// UTF-8 encoded. In practice, all significant parts of the file must be ASCII, +/// so Latin-1 or Windows-1252 encoding will also work fine. +/// +/// If the file content is available as a string, its [String.codeUnits] can +/// be used as the `source` argument of this function. +/// +/// The [baseLocation] is used as a base URI to resolve all relative +/// URI references against. +/// If the content was read from a file, `baseLocation` should be the +/// location of that file. +/// +/// Returns a simple package configuration where each package's +/// [Package.packageUriRoot] is the same as its [Package.root] +/// and it has no [Package.languageVersion]. +PackageConfig parse( + List source, Uri baseLocation, void Function(Object error) onError) { + if (baseLocation.isScheme('package')) { + onError(PackageConfigArgumentError( + baseLocation, 'baseLocation', 'Must not be a package: URI')); + return PackageConfig.empty; + } + var index = 0; + var packages = []; + var packageNames = {}; + while (index < source.length) { + var ignoreLine = false; + var start = index; + var separatorIndex = -1; + var end = source.length; + var char = source[index++]; + if (char == $cr || char == $lf) { + continue; + } + if (char == $colon) { + onError(PackageConfigFormatException( + 'Missing package name', source, index - 1)); + ignoreLine = true; // Ignore if package name is invalid. + } else { + ignoreLine = char == $hash; // Ignore if comment. + } + var queryStart = -1; + var fragmentStart = -1; + while (index < source.length) { + char = source[index++]; + if (char == $colon && separatorIndex < 0) { + separatorIndex = index - 1; + } else if (char == $cr || char == $lf) { + end = index - 1; + break; + } else if (char == $question && queryStart < 0 && fragmentStart < 0) { + queryStart = index - 1; + } else if (char == $hash && fragmentStart < 0) { + fragmentStart = index - 1; + } + } + if (ignoreLine) continue; + if (separatorIndex < 0) { + onError( + PackageConfigFormatException("No ':' on line", source, index - 1)); + continue; + } + var packageName = String.fromCharCodes(source, start, separatorIndex); + var invalidIndex = checkPackageName(packageName); + if (invalidIndex >= 0) { + onError(PackageConfigFormatException( + 'Not a valid package name', source, start + invalidIndex)); + continue; + } + if (queryStart >= 0) { + onError(PackageConfigFormatException( + 'Location URI must not have query', source, queryStart)); + end = queryStart; + } else if (fragmentStart >= 0) { + onError(PackageConfigFormatException( + 'Location URI must not have fragment', source, fragmentStart)); + end = fragmentStart; + } + var packageValue = String.fromCharCodes(source, separatorIndex + 1, end); + Uri packageLocation; + try { + packageLocation = Uri.parse(packageValue); + } on FormatException catch (e) { + onError(PackageConfigFormatException.from(e)); + continue; + } + var relativeRoot = !hasAbsolutePath(packageLocation); + packageLocation = baseLocation.resolveUri(packageLocation); + if (packageLocation.isScheme('package')) { + onError(PackageConfigFormatException( + 'Package URI as location for package', source, separatorIndex + 1)); + continue; + } + var path = packageLocation.path; + if (!path.endsWith('/')) { + path += '/'; + packageLocation = packageLocation.replace(path: path); + } + if (packageNames.contains(packageName)) { + onError(PackageConfigFormatException( + 'Same package name occurred more than once', source, start)); + continue; + } + var rootUri = packageLocation; + if (path.endsWith('/lib/')) { + // Assume default Pub package layout. Include package itself in root. + rootUri = + packageLocation.replace(path: path.substring(0, path.length - 4)); + } + var package = SimplePackage.validate(packageName, rootUri, packageLocation, + _languageVersion, null, relativeRoot, (error) { + if (error is ArgumentError) { + onError(PackageConfigFormatException(error.message.toString(), source)); + } else { + onError(error); + } + }); + if (package != null) { + packages.add(package); + packageNames.add(packageName); + } + } + return SimplePackageConfig(1, packages, null, onError); +} + +/// Writes the configuration to a [StringSink]. +/// +/// If [comment] is provided, the output will contain this comment +/// with `# ` in front of each line. +/// Lines are defined as ending in line feed (`'\n'`). If the final +/// line of the comment doesn't end in a line feed, one will be added. +/// +/// If [baseUri] is provided, package locations will be made relative +/// to the base URI, if possible, before writing. +void write(StringSink output, PackageConfig config, + {Uri? baseUri, String? comment}) { + if (baseUri != null && !baseUri.isAbsolute) { + throw PackageConfigArgumentError(baseUri, 'baseUri', 'Must be absolute'); + } + + if (comment != null) { + var lines = comment.split('\n'); + if (lines.last.isEmpty) lines.removeLast(); + for (var commentLine in lines) { + output.write('# '); + output.writeln(commentLine); + } + } else { + output.write('# generated by package:package_config at '); + output.write(DateTime.now()); + output.writeln(); + } + for (var package in config.packages) { + var packageName = package.name; + var uri = package.packageUriRoot; + // Validate packageName. + if (!isValidPackageName(packageName)) { + throw PackageConfigArgumentError( + config, 'config', '"$packageName" is not a valid package name'); + } + if (uri.scheme == 'package') { + throw PackageConfigArgumentError( + config, 'config', 'Package location must not be a package URI: $uri'); + } + output.write(packageName); + output.write(':'); + // If baseUri is provided, make the URI relative to baseUri. + if (baseUri != null) { + uri = relativizeUri(uri, baseUri)!; + } + if (!uri.path.endsWith('/')) { + uri = uri.replace(path: '${uri.path}/'); + } + output.write(uri); + output.writeln(); + } +} diff --git a/pkgs/package_config/lib/src/util.dart b/pkgs/package_config/lib/src/util.dart new file mode 100644 index 000000000..4f0210cda --- /dev/null +++ b/pkgs/package_config/lib/src/util.dart @@ -0,0 +1,253 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Utility methods used by more than one library in the package. +library; + +import 'errors.dart'; + +// All ASCII characters that are valid in a package name, with space +// for all the invalid ones (including space). +const String _validPackageNameCharacters = + r" ! $ &'()*+,-. 0123456789 ; = " + r'@ABCDEFGHIJKLMNOPQRSTUVWXYZ _ abcdefghijklmnopqrstuvwxyz ~ '; + +/// Tests whether something is a valid Dart package name. +bool isValidPackageName(String string) { + return checkPackageName(string) < 0; +} + +/// Check if a string is a valid package name. +/// +/// Valid package names contain only characters in [_validPackageNameCharacters] +/// and must contain at least one non-'.' character. +/// +/// Returns `-1` if the string is valid. +/// Otherwise returns the index of the first invalid character, +/// or `string.length` if the string contains no non-'.' character. +int checkPackageName(String string) { + // Becomes non-zero if any non-'.' character is encountered. + var nonDot = 0; + for (var i = 0; i < string.length; i++) { + var c = string.codeUnitAt(i); + if (c > 0x7f || _validPackageNameCharacters.codeUnitAt(c) <= $space) { + return i; + } + nonDot += c ^ $dot; + } + if (nonDot == 0) return string.length; + return -1; +} + +/// Validate that a [Uri] is a valid `package:` URI. +/// +/// Used to validate user input. +/// +/// Returns the package name extracted from the package URI, +/// which is the path segment between `package:` and the first `/`. +String checkValidPackageUri(Uri packageUri, String name) { + if (packageUri.scheme != 'package') { + throw PackageConfigArgumentError(packageUri, name, 'Not a package: URI'); + } + if (packageUri.hasAuthority) { + throw PackageConfigArgumentError( + packageUri, name, 'Package URIs must not have a host part'); + } + if (packageUri.hasQuery) { + // A query makes no sense if resolved to a file: URI. + throw PackageConfigArgumentError( + packageUri, name, 'Package URIs must not have a query part'); + } + if (packageUri.hasFragment) { + // We could leave the fragment after the URL when resolving, + // but it would be odd if "package:foo/foo.dart#1" and + // "package:foo/foo.dart#2" were considered different libraries. + // Keep the syntax open in case we ever get multiple libraries in one file. + throw PackageConfigArgumentError( + packageUri, name, 'Package URIs must not have a fragment part'); + } + if (packageUri.path.startsWith('/')) { + throw PackageConfigArgumentError( + packageUri, name, "Package URIs must not start with a '/'"); + } + var firstSlash = packageUri.path.indexOf('/'); + if (firstSlash == -1) { + throw PackageConfigArgumentError(packageUri, name, + "Package URIs must start with the package name followed by a '/'"); + } + var packageName = packageUri.path.substring(0, firstSlash); + var badIndex = checkPackageName(packageName); + if (badIndex >= 0) { + if (packageName.isEmpty) { + throw PackageConfigArgumentError( + packageUri, name, 'Package names mus be non-empty'); + } + if (badIndex == packageName.length) { + throw PackageConfigArgumentError(packageUri, name, + "Package names must contain at least one non-'.' character"); + } + assert(badIndex < packageName.length); + var badCharCode = packageName.codeUnitAt(badIndex); + var badChar = 'U+${badCharCode.toRadixString(16).padLeft(4, '0')}'; + if (badCharCode >= 0x20 && badCharCode <= 0x7e) { + // Printable character. + badChar = "'${packageName[badIndex]}' ($badChar)"; + } + throw PackageConfigArgumentError( + packageUri, name, 'Package names must not contain $badChar'); + } + return packageName; +} + +/// Checks whether URI is just an absolute directory. +/// +/// * It must have a scheme. +/// * It must not have a query or fragment. +/// * The path must end with `/`. +bool isAbsoluteDirectoryUri(Uri uri) { + if (uri.hasQuery) return false; + if (uri.hasFragment) return false; + if (!uri.hasScheme) return false; + var path = uri.path; + if (!path.endsWith('/')) return false; + return true; +} + +/// Whether the former URI is a prefix of the latter. +bool isUriPrefix(Uri prefix, Uri path) { + assert(!prefix.hasFragment); + assert(!prefix.hasQuery); + assert(!path.hasQuery); + assert(!path.hasFragment); + assert(prefix.path.endsWith('/')); + return path.toString().startsWith(prefix.toString()); +} + +/// Finds the first non-JSON-whitespace character in a file. +/// +/// Used to heuristically detect whether a file is a JSON file or an .ini file. +int firstNonWhitespaceChar(List bytes) { + for (var i = 0; i < bytes.length; i++) { + var char = bytes[i]; + if (char != 0x20 && char != 0x09 && char != 0x0a && char != 0x0d) { + return char; + } + } + return -1; +} + +/// Appends a trailing `/` if the path doesn't end with one. +String trailingSlash(String path) { + if (path.isEmpty || path.endsWith('/')) return path; + return '$path/'; +} + +/// Whether a URI should not be considered relative to the base URI. +/// +/// Used to determine whether a parsed root URI is relative +/// to the configuration file or not. +/// If it is relative, then it's rewritten as relative when +/// output again later. If not, it's output as absolute. +bool hasAbsolutePath(Uri uri) => + uri.hasScheme || uri.hasAuthority || uri.hasAbsolutePath; + +/// Attempts to return a relative path-only URI for [uri]. +/// +/// First removes any query or fragment part from [uri]. +/// +/// If [uri] is already relative (has no scheme), it's returned as-is. +/// If that is not desired, the caller can pass `baseUri.resolveUri(uri)` +/// as the [uri] instead. +/// +/// If the [uri] has a scheme or authority part which differs from +/// the [baseUri], or if there is no overlap in the paths of the +/// two URIs at all, the [uri] is returned as-is. +/// +/// Otherwise the result is a path-only URI which satisfies +/// `baseUri.resolveUri(result) == uri`, +/// +/// The `baseUri` must be absolute. +Uri? relativizeUri(Uri? uri, Uri? baseUri) { + if (baseUri == null) return uri; + assert(baseUri.isAbsolute); + if (uri!.hasQuery || uri.hasFragment) { + uri = Uri( + scheme: uri.scheme, + userInfo: uri.hasAuthority ? uri.userInfo : null, + host: uri.hasAuthority ? uri.host : null, + port: uri.hasAuthority ? uri.port : null, + path: uri.path); + } + + // Already relative. We assume the caller knows what they are doing. + if (!uri.isAbsolute) return uri; + + if (baseUri.scheme != uri.scheme) { + return uri; + } + + // If authority differs, we could remove the scheme, but it's not worth it. + if (uri.hasAuthority != baseUri.hasAuthority) return uri; + if (uri.hasAuthority) { + if (uri.userInfo != baseUri.userInfo || + uri.host.toLowerCase() != baseUri.host.toLowerCase() || + uri.port != baseUri.port) { + return uri; + } + } + + baseUri = baseUri.normalizePath(); + var base = [...baseUri.pathSegments]; + if (base.isNotEmpty) base.removeLast(); + uri = uri.normalizePath(); + var target = [...uri.pathSegments]; + if (target.isNotEmpty && target.last.isEmpty) target.removeLast(); + var index = 0; + while (index < base.length && index < target.length) { + if (base[index] != target[index]) { + break; + } + index++; + } + if (index == base.length) { + if (index == target.length) { + return Uri(path: './'); + } + return Uri(path: target.skip(index).join('/')); + } else if (index > 0) { + var buffer = StringBuffer(); + for (var n = base.length - index; n > 0; --n) { + buffer.write('../'); + } + buffer.writeAll(target.skip(index), '/'); + return Uri(path: buffer.toString()); + } else { + return uri; + } +} + +// Character constants used by this package. +/// "Line feed" control character. +const int $lf = 0x0a; + +/// "Carriage return" control character. +const int $cr = 0x0d; + +/// Space character. +const int $space = 0x20; + +/// Character `#`. +const int $hash = 0x23; + +/// Character `.`. +const int $dot = 0x2e; + +/// Character `:`. +const int $colon = 0x3a; + +/// Character `?`. +const int $question = 0x3f; + +/// Character `{`. +const int $lbrace = 0x7b; diff --git a/pkgs/package_config/lib/src/util_io.dart b/pkgs/package_config/lib/src/util_io.dart new file mode 100644 index 000000000..4680eefd4 --- /dev/null +++ b/pkgs/package_config/lib/src/util_io.dart @@ -0,0 +1,108 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Utility methods requiring dart:io and used by more than one library in the +/// package. +library; + +import 'dart:io'; +import 'dart:typed_data'; + +Future defaultLoader(Uri uri) async { + if (uri.isScheme('file')) { + var file = File.fromUri(uri); + try { + return await file.readAsBytes(); + } catch (_) { + return null; + } + } + if (uri.isScheme('http') || uri.isScheme('https')) { + return _httpGet(uri); + } + throw UnsupportedError('Default URI unsupported scheme: $uri'); +} + +Future _httpGet(Uri uri) async { + assert(uri.isScheme('http') || uri.isScheme('https')); + var client = HttpClient(); + var request = await client.getUrl(uri); + var response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + return null; + } + var splitContent = await response.toList(); + var totalLength = 0; + if (splitContent.length == 1) { + var part = splitContent[0]; + if (part is Uint8List) { + return part; + } + } + for (var list in splitContent) { + totalLength += list.length; + } + var result = Uint8List(totalLength); + var offset = 0; + for (var contentPart in splitContent as Iterable) { + result.setRange(offset, offset + contentPart.length, contentPart); + offset += contentPart.length; + } + return result; +} + +/// The file name of a path. +/// +/// The file name is everything after the last occurrence of +/// [Platform.pathSeparator], or the entire string if no +/// path separator occurs in the string. +String fileName(String path) { + var separator = Platform.pathSeparator; + var lastSeparator = path.lastIndexOf(separator); + if (lastSeparator < 0) return path; + return path.substring(lastSeparator + separator.length); +} + +/// The directory name of a path. +/// +/// The directory name is everything before the last occurrence of +/// [Platform.pathSeparator], or the empty string if no +/// path separator occurs in the string. +String dirName(String path) { + var separator = Platform.pathSeparator; + var lastSeparator = path.lastIndexOf(separator); + if (lastSeparator < 0) return ''; + return path.substring(0, lastSeparator); +} + +/// Join path parts with the [Platform.pathSeparator]. +/// +/// If a part ends with a path separator, then no extra separator is +/// inserted. +String pathJoin(String part1, String part2, [String? part3]) { + var separator = Platform.pathSeparator; + var separator1 = part1.endsWith(separator) ? '' : separator; + if (part3 == null) { + return '$part1$separator1$part2'; + } + var separator2 = part2.endsWith(separator) ? '' : separator; + return '$part1$separator1$part2$separator2$part3'; +} + +/// Join an unknown number of path parts with [Platform.pathSeparator]. +/// +/// If a part ends with a path separator, then no extra separator is +/// inserted. +String pathJoinAll(Iterable parts) { + var buffer = StringBuffer(); + var separator = ''; + for (var part in parts) { + buffer + ..write(separator) + ..write(part); + separator = + part.endsWith(Platform.pathSeparator) ? '' : Platform.pathSeparator; + } + return buffer.toString(); +} diff --git a/pkgs/package_config/pubspec.yaml b/pkgs/package_config/pubspec.yaml new file mode 100644 index 000000000..28f3e1364 --- /dev/null +++ b/pkgs/package_config/pubspec.yaml @@ -0,0 +1,14 @@ +name: package_config +version: 2.1.1 +description: Support for reading and writing Dart Package Configuration files. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/package_config + +environment: + sdk: ^3.4.0 + +dependencies: + path: ^1.8.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + test: ^1.16.0 diff --git a/pkgs/package_config/test/bench.dart b/pkgs/package_config/test/bench.dart new file mode 100644 index 000000000..8428481f7 --- /dev/null +++ b/pkgs/package_config/test/bench.dart @@ -0,0 +1,71 @@ +// Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:package_config/src/errors.dart'; +import 'package:package_config/src/package_config_json.dart'; + +void bench(final int size, final bool doPrint) { + var sb = StringBuffer(); + sb.writeln('{'); + sb.writeln('"configVersion": 2,'); + sb.writeln('"packages": ['); + for (var i = 0; i < size; i++) { + if (i != 0) { + sb.writeln(','); + } + sb.writeln('{'); + sb.writeln(' "name": "p_$i",'); + sb.writeln(' "rootUri": "file:///p_$i/",'); + sb.writeln(' "packageUri": "lib/",'); + sb.writeln(' "languageVersion": "2.5",'); + sb.writeln(' "nonstandard": true'); + sb.writeln('}'); + } + sb.writeln('],'); + sb.writeln('"generator": "pub",'); + sb.writeln('"other": [42]'); + sb.writeln('}'); + var stopwatch = Stopwatch()..start(); + var config = parsePackageConfigBytes( + // ignore: unnecessary_cast + utf8.encode(sb.toString()) as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), + throwError, + ); + final read = stopwatch.elapsedMilliseconds; + + stopwatch.reset(); + for (var i = 0; i < size; i++) { + if (config.packageOf(Uri.parse('file:///p_$i/lib/src/foo.dart'))!.name != + 'p_$i') { + throw StateError('Unexpected result!'); + } + } + final lookup = stopwatch.elapsedMilliseconds; + + if (doPrint) { + print('Read file with $size packages in $read ms, ' + 'looked up all packages in $lookup ms'); + } +} + +void main(List args) { + if (args.length != 1 && args.length != 2) { + throw ArgumentError('Expects arguments: ?'); + } + final size = int.parse(args[0]); + if (args.length > 1) { + final warmups = int.parse(args[1]); + print('Performing $warmups warmup iterations.'); + for (var i = 0; i < warmups; i++) { + bench(10, false); + } + } + + // Benchmark. + bench(size, true); +} diff --git a/pkgs/package_config/test/discovery_test.dart b/pkgs/package_config/test/discovery_test.dart new file mode 100644 index 000000000..6d1b65529 --- /dev/null +++ b/pkgs/package_config/test/discovery_test.dart @@ -0,0 +1,346 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('vm') +library; + +import 'dart:io'; + +import 'package:package_config/package_config.dart'; +import 'package:test/test.dart'; + +import 'src/util.dart'; +import 'src/util_io.dart'; + +const packagesFile = ''' +# A comment +foo:file:///dart/packages/foo/ +bar:/dart/packages/bar/ +baz:packages/baz/ +'''; + +const packageConfigFile = ''' +{ + "configVersion": 2, + "packages": [ + { + "name": "foo", + "rootUri": "file:///dart/packages/foo/" + }, + { + "name": "bar", + "rootUri": "/dart/packages/bar/" + }, + { + "name": "baz", + "rootUri": "../packages/baz/" + } + ], + "extra": [42] +} +'''; + +void validatePackagesFile(PackageConfig resolver, Directory directory) { + expect(resolver, isNotNull); + expect(resolver.resolve(pkg('foo', 'bar/baz')), + equals(Uri.parse('file:///dart/packages/foo/bar/baz'))); + expect(resolver.resolve(pkg('bar', 'baz/qux')), + equals(Uri.parse('file:///dart/packages/bar/baz/qux'))); + expect(resolver.resolve(pkg('baz', 'qux/foo')), + equals(Uri.directory(directory.path).resolve('packages/baz/qux/foo'))); + expect([for (var p in resolver.packages) p.name], + unorderedEquals(['foo', 'bar', 'baz'])); +} + +void main() { + group('findPackages', () { + // Finds package_config.json if there. + fileTest('package_config.json', { + '.packages': 'invalid .packages file', + 'script.dart': 'main(){}', + 'packages': {'shouldNotBeFound': {}}, + '.dart_tool': { + 'package_config.json': packageConfigFile, + } + }, (Directory directory) async { + var config = (await findPackageConfig(directory))!; + expect(config.version, 2); // Found package_config.json file. + validatePackagesFile(config, directory); + }); + + // Finds .packages if no package_config.json. + fileTest('.packages', { + '.packages': packagesFile, + 'script.dart': 'main(){}', + 'packages': {'shouldNotBeFound': {}} + }, (Directory directory) async { + var config = (await findPackageConfig(directory))!; + expect(config.version, 1); // Found .packages file. + validatePackagesFile(config, directory); + }); + + // Finds package_config.json in super-directory. + fileTest('package_config.json recursive', { + '.packages': packagesFile, + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + 'subdir': { + 'script.dart': 'main(){}', + } + }, (Directory directory) async { + var config = (await findPackageConfig(subdir(directory, 'subdir/')))!; + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + + // Finds .packages in super-directory. + fileTest('.packages recursive', { + '.packages': packagesFile, + 'subdir': {'script.dart': 'main(){}'} + }, (Directory directory) async { + var config = (await findPackageConfig(subdir(directory, 'subdir/')))!; + expect(config.version, 1); + validatePackagesFile(config, directory); + }); + + // Does not find a packages/ directory, and returns null if nothing found. + fileTest('package directory packages not supported', { + 'packages': { + 'foo': {}, + } + }, (Directory directory) async { + var config = await findPackageConfig(directory); + expect(config, null); + }); + + group('throws', () { + fileTest('invalid .packages', { + '.packages': 'not a .packages file', + }, (Directory directory) { + expect(findPackageConfig(directory), throwsA(isA())); + }); + + fileTest('invalid .packages as JSON', { + '.packages': packageConfigFile, + }, (Directory directory) { + expect(findPackageConfig(directory), throwsA(isA())); + }); + + fileTest('invalid .packages', { + '.dart_tool': { + 'package_config.json': 'not a JSON file', + } + }, (Directory directory) { + expect(findPackageConfig(directory), throwsA(isA())); + }); + + fileTest('invalid .packages as INI', { + '.dart_tool': { + 'package_config.json': packagesFile, + } + }, (Directory directory) { + expect(findPackageConfig(directory), throwsA(isA())); + }); + }); + + group('handles error', () { + fileTest('invalid .packages', { + '.packages': 'not a .packages file', + }, (Directory directory) async { + var hadError = false; + await findPackageConfig(directory, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + + fileTest('invalid .packages as JSON', { + '.packages': packageConfigFile, + }, (Directory directory) async { + var hadError = false; + await findPackageConfig(directory, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + + fileTest('invalid package_config not JSON', { + '.dart_tool': { + 'package_config.json': 'not a JSON file', + } + }, (Directory directory) async { + var hadError = false; + await findPackageConfig(directory, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + + fileTest('invalid package config as INI', { + '.dart_tool': { + 'package_config.json': packagesFile, + } + }, (Directory directory) async { + var hadError = false; + await findPackageConfig(directory, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + }); + + // Does not find .packages if no package_config.json and minVersion > 1. + fileTest('.packages ignored', { + '.packages': packagesFile, + 'script.dart': 'main(){}' + }, (Directory directory) async { + var config = await findPackageConfig(directory, minVersion: 2); + expect(config, null); + }); + + // Finds package_config.json in super-directory, with .packages in + // subdir and minVersion > 1. + fileTest('package_config.json recursive .packages ignored', { + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + 'subdir': { + '.packages': packagesFile, + 'script.dart': 'main(){}', + } + }, (Directory directory) async { + var config = (await findPackageConfig(subdir(directory, 'subdir/'), + minVersion: 2))!; + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + }); + + group('loadPackageConfig', () { + // Load a specific files + group('package_config.json', () { + var files = { + '.packages': packagesFile, + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + }; + fileTest('directly', files, (Directory directory) async { + var file = + dirFile(subdir(directory, '.dart_tool'), 'package_config.json'); + var config = await loadPackageConfig(file); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + fileTest('indirectly through .packages', files, + (Directory directory) async { + var file = dirFile(directory, '.packages'); + var config = await loadPackageConfig(file); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + fileTest('prefer .packages', files, (Directory directory) async { + var file = dirFile(directory, '.packages'); + var config = await loadPackageConfig(file, preferNewest: false); + expect(config.version, 1); + validatePackagesFile(config, directory); + }); + }); + + fileTest('package_config.json non-default name', { + '.packages': packagesFile, + 'subdir': { + 'pheldagriff': packageConfigFile, + }, + }, (Directory directory) async { + var file = dirFile(directory, 'subdir/pheldagriff'); + var config = await loadPackageConfig(file); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + + fileTest('package_config.json named .packages', { + 'subdir': { + '.packages': packageConfigFile, + }, + }, (Directory directory) async { + var file = dirFile(directory, 'subdir/.packages'); + var config = await loadPackageConfig(file); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + + fileTest('.packages', { + '.packages': packagesFile, + }, (Directory directory) async { + var file = dirFile(directory, '.packages'); + var config = await loadPackageConfig(file); + expect(config.version, 1); + validatePackagesFile(config, directory); + }); + + fileTest('.packages non-default name', { + 'pheldagriff': packagesFile, + }, (Directory directory) async { + var file = dirFile(directory, 'pheldagriff'); + var config = await loadPackageConfig(file); + expect(config.version, 1); + validatePackagesFile(config, directory); + }); + + fileTest('no config found', {}, (Directory directory) { + var file = dirFile(directory, 'anyname'); + expect( + () => loadPackageConfig(file), throwsA(isA())); + }); + + fileTest('no config found, handled', {}, (Directory directory) async { + var file = dirFile(directory, 'anyname'); + var hadError = false; + await loadPackageConfig(file, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + + fileTest('specified file syntax error', { + 'anyname': 'syntax error', + }, (Directory directory) { + var file = dirFile(directory, 'anyname'); + expect(() => loadPackageConfig(file), throwsFormatException); + }); + + // Find package_config.json in subdir even if initial file syntax error. + fileTest('specified file syntax onError', { + '.packages': 'syntax error', + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + }, (Directory directory) async { + var file = dirFile(directory, '.packages'); + var config = await loadPackageConfig(file); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + + // A file starting with `{` is a package_config.json file. + fileTest('file syntax error with {', { + '.packages': '{syntax error', + }, (Directory directory) { + var file = dirFile(directory, '.packages'); + expect(() => loadPackageConfig(file), throwsFormatException); + }); + }); +} diff --git a/pkgs/package_config/test/discovery_uri_test.dart b/pkgs/package_config/test/discovery_uri_test.dart new file mode 100644 index 000000000..542bf0a65 --- /dev/null +++ b/pkgs/package_config/test/discovery_uri_test.dart @@ -0,0 +1,310 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('vm') +library; + +import 'package:package_config/package_config.dart'; +import 'package:test/test.dart'; + +import 'src/util.dart'; + +const packagesFile = ''' +# A comment +foo:file:///dart/packages/foo/ +bar:/dart/packages/bar/ +baz:packages/baz/ +'''; + +const packageConfigFile = ''' +{ + "configVersion": 2, + "packages": [ + { + "name": "foo", + "rootUri": "file:///dart/packages/foo/" + }, + { + "name": "bar", + "rootUri": "/dart/packages/bar/" + }, + { + "name": "baz", + "rootUri": "../packages/baz/" + } + ], + "extra": [42] +} +'''; + +void validatePackagesFile(PackageConfig resolver, Uri directory) { + expect(resolver, isNotNull); + expect(resolver.resolve(pkg('foo', 'bar/baz')), + equals(Uri.parse('file:///dart/packages/foo/bar/baz'))); + expect(resolver.resolve(pkg('bar', 'baz/qux')), + equals(directory.resolve('/dart/packages/bar/baz/qux'))); + expect(resolver.resolve(pkg('baz', 'qux/foo')), + equals(directory.resolve('packages/baz/qux/foo'))); + expect([for (var p in resolver.packages) p.name], + unorderedEquals(['foo', 'bar', 'baz'])); +} + +void main() { + group('findPackages', () { + // Finds package_config.json if there. + loaderTest('package_config.json', { + '.packages': 'invalid .packages file', + 'script.dart': 'main(){}', + 'packages': {'shouldNotBeFound': {}}, + '.dart_tool': { + 'package_config.json': packageConfigFile, + } + }, (directory, loader) async { + var config = (await findPackageConfigUri(directory, loader: loader))!; + expect(config.version, 2); // Found package_config.json file. + validatePackagesFile(config, directory); + }); + + // Finds .packages if no package_config.json. + loaderTest('.packages', { + '.packages': packagesFile, + 'script.dart': 'main(){}', + 'packages': {'shouldNotBeFound': {}} + }, (directory, loader) async { + var config = (await findPackageConfigUri(directory, loader: loader))!; + expect(config.version, 1); // Found .packages file. + validatePackagesFile(config, directory); + }); + + // Finds package_config.json in super-directory. + loaderTest('package_config.json recursive', { + '.packages': packagesFile, + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + 'subdir': { + 'script.dart': 'main(){}', + } + }, (directory, loader) async { + var config = (await findPackageConfigUri(directory.resolve('subdir/'), + loader: loader))!; + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + + // Finds .packages in super-directory. + loaderTest('.packages recursive', { + '.packages': packagesFile, + 'subdir': {'script.dart': 'main(){}'} + }, (directory, loader) async { + var config = (await findPackageConfigUri(directory.resolve('subdir/'), + loader: loader))!; + expect(config.version, 1); + validatePackagesFile(config, directory); + }); + + // Does not find a packages/ directory, and returns null if nothing found. + loaderTest('package directory packages not supported', { + 'packages': { + 'foo': {}, + } + }, (Uri directory, loader) async { + var config = await findPackageConfigUri(directory, loader: loader); + expect(config, null); + }); + + loaderTest('invalid .packages', { + '.packages': 'not a .packages file', + }, (Uri directory, loader) { + expect(() => findPackageConfigUri(directory, loader: loader), + throwsA(isA())); + }); + + loaderTest('invalid .packages as JSON', { + '.packages': packageConfigFile, + }, (Uri directory, loader) { + expect(() => findPackageConfigUri(directory, loader: loader), + throwsA(isA())); + }); + + loaderTest('invalid .packages', { + '.dart_tool': { + 'package_config.json': 'not a JSON file', + } + }, (Uri directory, loader) { + expect(() => findPackageConfigUri(directory, loader: loader), + throwsA(isA())); + }); + + loaderTest('invalid .packages as INI', { + '.dart_tool': { + 'package_config.json': packagesFile, + } + }, (Uri directory, loader) { + expect(() => findPackageConfigUri(directory, loader: loader), + throwsA(isA())); + }); + + // Does not find .packages if no package_config.json and minVersion > 1. + loaderTest('.packages ignored', { + '.packages': packagesFile, + 'script.dart': 'main(){}' + }, (directory, loader) async { + var config = + await findPackageConfigUri(directory, minVersion: 2, loader: loader); + expect(config, null); + }); + + // Finds package_config.json in super-directory, with .packages in + // subdir and minVersion > 1. + loaderTest('package_config.json recursive ignores .packages', { + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + 'subdir': { + '.packages': packagesFile, + 'script.dart': 'main(){}', + } + }, (directory, loader) async { + var config = (await findPackageConfigUri(directory.resolve('subdir/'), + minVersion: 2, loader: loader))!; + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + }); + + group('loadPackageConfig', () { + // Load a specific files + group('package_config.json', () { + var files = { + '.packages': packagesFile, + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + }; + loaderTest('directly', files, (Uri directory, loader) async { + var file = directory.resolve('.dart_tool/package_config.json'); + var config = await loadPackageConfigUri(file, loader: loader); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + loaderTest('indirectly through .packages', files, + (Uri directory, loader) async { + var file = directory.resolve('.packages'); + var config = await loadPackageConfigUri(file, loader: loader); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + }); + + loaderTest('package_config.json non-default name', { + '.packages': packagesFile, + 'subdir': { + 'pheldagriff': packageConfigFile, + }, + }, (Uri directory, loader) async { + var file = directory.resolve('subdir/pheldagriff'); + var config = await loadPackageConfigUri(file, loader: loader); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + + loaderTest('package_config.json named .packages', { + 'subdir': { + '.packages': packageConfigFile, + }, + }, (Uri directory, loader) async { + var file = directory.resolve('subdir/.packages'); + var config = await loadPackageConfigUri(file, loader: loader); + expect(config.version, 2); + validatePackagesFile(config, directory); + }); + + loaderTest('.packages', { + '.packages': packagesFile, + }, (Uri directory, loader) async { + var file = directory.resolve('.packages'); + var config = await loadPackageConfigUri(file, loader: loader); + expect(config.version, 1); + validatePackagesFile(config, directory); + }); + + loaderTest('.packages non-default name', { + 'pheldagriff': packagesFile, + }, (Uri directory, loader) async { + var file = directory.resolve('pheldagriff'); + var config = await loadPackageConfigUri(file, loader: loader); + expect(config.version, 1); + validatePackagesFile(config, directory); + }); + + loaderTest('no config found', {}, (Uri directory, loader) { + var file = directory.resolve('anyname'); + expect(() => loadPackageConfigUri(file, loader: loader), + throwsA(isA())); + }); + + loaderTest('no config found, handle error', {}, + (Uri directory, loader) async { + var file = directory.resolve('anyname'); + var hadError = false; + await loadPackageConfigUri(file, + loader: loader, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + + loaderTest('specified file syntax error', { + 'anyname': 'syntax error', + }, (Uri directory, loader) { + var file = directory.resolve('anyname'); + expect(() => loadPackageConfigUri(file, loader: loader), + throwsFormatException); + }); + + loaderTest('specified file syntax onError', { + 'anyname': 'syntax error', + }, (directory, loader) async { + var file = directory.resolve('anyname'); + var hadError = false; + await loadPackageConfigUri(file, + loader: loader, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + + // Don't look for package_config.json if original file not named .packages. + loaderTest('specified file syntax error with alternative', { + 'anyname': 'syntax error', + '.dart_tool': { + 'package_config.json': packageConfigFile, + }, + }, (directory, loader) async { + var file = directory.resolve('anyname'); + expect(() => loadPackageConfigUri(file, loader: loader), + throwsFormatException); + }); + + // A file starting with `{` is a package_config.json file. + loaderTest('file syntax error with {', { + '.packages': '{syntax error', + }, (directory, loader) async { + var file = directory.resolve('.packages'); + var hadError = false; + await loadPackageConfigUri(file, + loader: loader, + onError: expectAsync1((error) { + hadError = true; + expect(error, isA()); + }, max: -1)); + expect(hadError, true); + }); + }); +} diff --git a/pkgs/package_config/test/package_config_impl_test.dart b/pkgs/package_config/test/package_config_impl_test.dart new file mode 100644 index 000000000..0f399636f --- /dev/null +++ b/pkgs/package_config/test/package_config_impl_test.dart @@ -0,0 +1,188 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert' show jsonDecode; + +import 'package:package_config/package_config_types.dart'; +import 'package:test/test.dart'; +import 'src/util.dart'; + +void main() { + var unique = Object(); + var root = Uri.file('/tmp/root/'); + + group('LanguageVersion', () { + test('minimal', () { + var version = LanguageVersion(3, 5); + expect(version.major, 3); + expect(version.minor, 5); + }); + + test('negative major', () { + expect(() => LanguageVersion(-1, 1), throwsArgumentError); + }); + + test('negative minor', () { + expect(() => LanguageVersion(1, -1), throwsArgumentError); + }); + + test('minimal parse', () { + var version = LanguageVersion.parse('3.5'); + expect(version.major, 3); + expect(version.minor, 5); + }); + + void failParse(String name, String input) { + test('$name - error', () { + expect(() => LanguageVersion.parse(input), + throwsA(isA())); + expect(() => LanguageVersion.parse(input), throwsFormatException); + var failed = false; + var actual = LanguageVersion.parse(input, onError: (_) { + failed = true; + }); + expect(failed, true); + expect(actual, isA()); + }); + } + + failParse('Leading zero major', '01.1'); + failParse('Leading zero minor', '1.01'); + failParse('Sign+ major', '+1.1'); + failParse('Sign- major', '-1.1'); + failParse('Sign+ minor', '1.+1'); + failParse('Sign- minor', '1.-1'); + failParse('WhiteSpace 1', ' 1.1'); + failParse('WhiteSpace 2', '1 .1'); + failParse('WhiteSpace 3', '1. 1'); + failParse('WhiteSpace 4', '1.1 '); + }); + + group('Package', () { + test('minimal', () { + var package = Package('name', root, extraData: unique); + expect(package.name, 'name'); + expect(package.root, root); + expect(package.packageUriRoot, root); + expect(package.languageVersion, null); + expect(package.extraData, same(unique)); + }); + + test('absolute package root', () { + var version = LanguageVersion(1, 1); + var absolute = root.resolve('foo/bar/'); + var package = Package('name', root, + packageUriRoot: absolute, + relativeRoot: false, + languageVersion: version, + extraData: unique); + expect(package.name, 'name'); + expect(package.root, root); + expect(package.packageUriRoot, absolute); + expect(package.languageVersion, version); + expect(package.extraData, same(unique)); + expect(package.relativeRoot, false); + }); + + test('relative package root', () { + var relative = Uri.parse('foo/bar/'); + var absolute = root.resolveUri(relative); + var package = Package('name', root, + packageUriRoot: relative, relativeRoot: true, extraData: unique); + expect(package.name, 'name'); + expect(package.root, root); + expect(package.packageUriRoot, absolute); + expect(package.relativeRoot, true); + expect(package.languageVersion, null); + expect(package.extraData, same(unique)); + }); + + for (var badName in ['a/z', 'a:z', '', '...']) { + test("Invalid name '$badName'", () { + expect(() => Package(badName, root), throwsPackageConfigError); + }); + } + + test('Invalid root, not absolute', () { + expect( + () => Package('name', Uri.parse('/foo/')), throwsPackageConfigError); + }); + + test('Invalid root, not ending in slash', () { + expect(() => Package('name', Uri.parse('file:///foo')), + throwsPackageConfigError); + }); + + test('invalid package root, not ending in slash', () { + expect(() => Package('name', root, packageUriRoot: Uri.parse('foo')), + throwsPackageConfigError); + }); + + test('invalid package root, not inside root', () { + expect(() => Package('name', root, packageUriRoot: Uri.parse('../baz/')), + throwsPackageConfigError); + }); + }); + + group('package config', () { + test('empty', () { + var empty = PackageConfig([], extraData: unique); + expect(empty.version, 2); + expect(empty.packages, isEmpty); + expect(empty.extraData, same(unique)); + expect(empty.resolve(pkg('a', 'b')), isNull); + }); + + test('single', () { + var package = Package('name', root); + var single = PackageConfig([package], extraData: unique); + expect(single.version, 2); + expect(single.packages, hasLength(1)); + expect(single.extraData, same(unique)); + expect(single.resolve(pkg('a', 'b')), isNull); + var resolved = single.resolve(pkg('name', 'a/b')); + expect(resolved, root.resolve('a/b')); + }); + }); + test('writeString', () { + var config = PackageConfig([ + Package('foo', Uri.parse('file:///pkg/foo/'), + packageUriRoot: Uri.parse('file:///pkg/foo/lib/'), + relativeRoot: false, + languageVersion: LanguageVersion(2, 4), + extraData: {'foo': 'foo!'}), + Package('bar', Uri.parse('file:///pkg/bar/'), + packageUriRoot: Uri.parse('file:///pkg/bar/lib/'), + relativeRoot: true, + extraData: {'bar': 'bar!'}), + ], extraData: { + 'extra': 'data' + }); + var buffer = StringBuffer(); + PackageConfig.writeString(config, buffer, Uri.parse('file:///pkg/')); + var text = buffer.toString(); + var json = jsonDecode(text); // Is valid JSON. + expect(json, { + 'configVersion': 2, + 'packages': unorderedEquals([ + { + 'name': 'foo', + 'rootUri': 'file:///pkg/foo/', + 'packageUri': 'lib/', + 'languageVersion': '2.4', + 'foo': 'foo!', + }, + { + 'name': 'bar', + 'rootUri': 'bar/', + 'packageUri': 'lib/', + 'bar': 'bar!', + }, + ]), + 'extra': 'data', + }); + }); +} + +final Matcher throwsPackageConfigError = throwsA(isA()); diff --git a/pkgs/package_config/test/parse_test.dart b/pkgs/package_config/test/parse_test.dart new file mode 100644 index 000000000..a92b9bfcc --- /dev/null +++ b/pkgs/package_config/test/parse_test.dart @@ -0,0 +1,552 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:package_config/package_config_types.dart'; +import 'package:package_config/src/errors.dart'; +import 'package:package_config/src/package_config_json.dart'; +import 'package:package_config/src/packages_file.dart' as packages; +import 'package:test/test.dart'; + +import 'src/util.dart'; + +void main() { + group('.packages', () { + test('valid', () { + var packagesFile = '# Generated by pub yadda yadda\n' + 'foo:file:///foo/lib/\n' + 'bar:/bar/lib/\n' + 'baz:lib/\n'; + var result = packages.parse(utf8.encode(packagesFile), + Uri.parse('file:///tmp/file.dart'), throwError); + expect(result.version, 1); + expect({for (var p in result.packages) p.name}, {'foo', 'bar', 'baz'}); + expect(result.resolve(pkg('foo', 'foo.dart')), + Uri.parse('file:///foo/lib/foo.dart')); + expect(result.resolve(pkg('bar', 'bar.dart')), + Uri.parse('file:///bar/lib/bar.dart')); + expect(result.resolve(pkg('baz', 'baz.dart')), + Uri.parse('file:///tmp/lib/baz.dart')); + + var foo = result['foo']!; + expect(foo, isNotNull); + expect(foo.root, Uri.parse('file:///foo/')); + expect(foo.packageUriRoot, Uri.parse('file:///foo/lib/')); + expect(foo.languageVersion, LanguageVersion(2, 7)); + expect(foo.relativeRoot, false); + }); + + test('valid empty', () { + var packagesFile = '# Generated by pub yadda yadda\n'; + var result = packages.parse( + utf8.encode(packagesFile), Uri.file('/tmp/file.dart'), throwError); + expect(result.version, 1); + expect({for (var p in result.packages) p.name}, {}); + }); + + group('invalid', () { + var baseFile = Uri.file('/tmp/file.dart'); + void testThrows(String name, String content) { + test(name, () { + expect( + () => packages.parse(utf8.encode(content), baseFile, throwError), + throwsA(isA())); + }); + test('$name, handle error', () { + var hadError = false; + packages.parse(utf8.encode(content), baseFile, (error) { + hadError = true; + expect(error, isA()); + }); + expect(hadError, true); + }); + } + + testThrows('repeated package name', 'foo:lib/\nfoo:lib\n'); + testThrows('no colon', 'foo\n'); + testThrows('empty package name', ':lib/\n'); + testThrows('dot only package name', '.:lib/\n'); + testThrows('dot only package name', '..:lib/\n'); + testThrows('invalid package name character', 'f\\o:lib/\n'); + testThrows('package URI', 'foo:package:bar/lib/'); + testThrows('location with query', 'f\\o:lib/?\n'); + testThrows('location with fragment', 'f\\o:lib/#\n'); + }); + }); + + group('package_config.json', () { + test('valid', () { + var packageConfigFile = ''' + { + "configVersion": 2, + "packages": [ + { + "name": "foo", + "rootUri": "file:///foo/", + "packageUri": "lib/", + "languageVersion": "2.5", + "nonstandard": true + }, + { + "name": "bar", + "rootUri": "/bar/", + "packageUri": "lib/", + "languageVersion": "9999.9999" + }, + { + "name": "baz", + "rootUri": "../", + "packageUri": "lib/" + }, + { + "name": "noslash", + "rootUri": "../noslash", + "packageUri": "lib" + } + ], + "generator": "pub", + "other": [42] + } + '''; + var config = parsePackageConfigBytes( + // ignore: unnecessary_cast + utf8.encode(packageConfigFile) as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), + throwError); + expect(config.version, 2); + expect({for (var p in config.packages) p.name}, + {'foo', 'bar', 'baz', 'noslash'}); + + expect(config.resolve(pkg('foo', 'foo.dart')), + Uri.parse('file:///foo/lib/foo.dart')); + expect(config.resolve(pkg('bar', 'bar.dart')), + Uri.parse('file:///bar/lib/bar.dart')); + expect(config.resolve(pkg('baz', 'baz.dart')), + Uri.parse('file:///tmp/lib/baz.dart')); + + var foo = config['foo']!; + expect(foo, isNotNull); + expect(foo.root, Uri.parse('file:///foo/')); + expect(foo.packageUriRoot, Uri.parse('file:///foo/lib/')); + expect(foo.languageVersion, LanguageVersion(2, 5)); + expect(foo.extraData, {'nonstandard': true}); + expect(foo.relativeRoot, false); + + var bar = config['bar']!; + expect(bar, isNotNull); + expect(bar.root, Uri.parse('file:///bar/')); + expect(bar.packageUriRoot, Uri.parse('file:///bar/lib/')); + expect(bar.languageVersion, LanguageVersion(9999, 9999)); + expect(bar.extraData, null); + expect(bar.relativeRoot, false); + + var baz = config['baz']!; + expect(baz, isNotNull); + expect(baz.root, Uri.parse('file:///tmp/')); + expect(baz.packageUriRoot, Uri.parse('file:///tmp/lib/')); + expect(baz.languageVersion, null); + expect(baz.relativeRoot, true); + + // No slash after root or package root. One is inserted. + var noslash = config['noslash']!; + expect(noslash, isNotNull); + expect(noslash.root, Uri.parse('file:///tmp/noslash/')); + expect(noslash.packageUriRoot, Uri.parse('file:///tmp/noslash/lib/')); + expect(noslash.languageVersion, null); + expect(noslash.relativeRoot, true); + + expect(config.extraData, { + 'generator': 'pub', + 'other': [42] + }); + }); + + test('valid other order', () { + // The ordering in the file is not important. + var packageConfigFile = ''' + { + "generator": "pub", + "other": [42], + "packages": [ + { + "languageVersion": "2.5", + "packageUri": "lib/", + "rootUri": "file:///foo/", + "name": "foo" + }, + { + "packageUri": "lib/", + "languageVersion": "9999.9999", + "rootUri": "/bar/", + "name": "bar" + }, + { + "packageUri": "lib/", + "name": "baz", + "rootUri": "../" + } + ], + "configVersion": 2 + } + '''; + var config = parsePackageConfigBytes( + // ignore: unnecessary_cast + utf8.encode(packageConfigFile) as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), + throwError); + expect(config.version, 2); + expect({for (var p in config.packages) p.name}, {'foo', 'bar', 'baz'}); + + expect(config.resolve(pkg('foo', 'foo.dart')), + Uri.parse('file:///foo/lib/foo.dart')); + expect(config.resolve(pkg('bar', 'bar.dart')), + Uri.parse('file:///bar/lib/bar.dart')); + expect(config.resolve(pkg('baz', 'baz.dart')), + Uri.parse('file:///tmp/lib/baz.dart')); + expect(config.extraData, { + 'generator': 'pub', + 'other': [42] + }); + }); + + // Check that a few minimal configurations are valid. + // These form the basis of invalid tests below. + var cfg = '"configVersion":2'; + var pkgs = '"packages":[]'; + var name = '"name":"foo"'; + var root = '"rootUri":"/foo/"'; + test('minimal', () { + var config = parsePackageConfigBytes( + // ignore: unnecessary_cast + utf8.encode('{$cfg,$pkgs}') as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), + throwError); + expect(config.version, 2); + expect(config.packages, isEmpty); + }); + test('minimal package', () { + // A package must have a name and a rootUri, the remaining properties + // are optional. + var config = parsePackageConfigBytes( + // ignore: unnecessary_cast + utf8.encode('{$cfg,"packages":[{$name,$root}]}') as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), + throwError); + expect(config.version, 2); + expect(config.packages.first.name, 'foo'); + }); + + test('nested packages', () { + var configBytes = utf8.encode(json.encode({ + 'configVersion': 2, + 'packages': [ + {'name': 'foo', 'rootUri': '/foo/', 'packageUri': 'lib/'}, + {'name': 'bar', 'rootUri': '/foo/bar/', 'packageUri': 'lib/'}, + {'name': 'baz', 'rootUri': '/foo/bar/baz/', 'packageUri': 'lib/'}, + {'name': 'qux', 'rootUri': '/foo/qux/', 'packageUri': 'lib/'}, + ] + })); + // ignore: unnecessary_cast + var config = parsePackageConfigBytes(configBytes as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), throwError); + expect(config.version, 2); + expect(config.packageOf(Uri.parse('file:///foo/lala/lala.dart'))!.name, + 'foo'); + expect(config.packageOf(Uri.parse('file:///foo/bar/lala.dart'))!.name, + 'bar'); + expect(config.packageOf(Uri.parse('file:///foo/bar/baz/lala.dart'))!.name, + 'baz'); + expect(config.packageOf(Uri.parse('file:///foo/qux/lala.dart'))!.name, + 'qux'); + expect(config.toPackageUri(Uri.parse('file:///foo/lib/diz')), + Uri.parse('package:foo/diz')); + expect(config.toPackageUri(Uri.parse('file:///foo/bar/lib/diz')), + Uri.parse('package:bar/diz')); + expect(config.toPackageUri(Uri.parse('file:///foo/bar/baz/lib/diz')), + Uri.parse('package:baz/diz')); + expect(config.toPackageUri(Uri.parse('file:///foo/qux/lib/diz')), + Uri.parse('package:qux/diz')); + }); + + test('nested packages 2', () { + var configBytes = utf8.encode(json.encode({ + 'configVersion': 2, + 'packages': [ + {'name': 'foo', 'rootUri': '/', 'packageUri': 'lib/'}, + {'name': 'bar', 'rootUri': '/bar/', 'packageUri': 'lib/'}, + {'name': 'baz', 'rootUri': '/bar/baz/', 'packageUri': 'lib/'}, + {'name': 'qux', 'rootUri': '/qux/', 'packageUri': 'lib/'}, + ] + })); + // ignore: unnecessary_cast + var config = parsePackageConfigBytes(configBytes as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), throwError); + expect(config.version, 2); + expect( + config.packageOf(Uri.parse('file:///lala/lala.dart'))!.name, 'foo'); + expect(config.packageOf(Uri.parse('file:///bar/lala.dart'))!.name, 'bar'); + expect(config.packageOf(Uri.parse('file:///bar/baz/lala.dart'))!.name, + 'baz'); + expect(config.packageOf(Uri.parse('file:///qux/lala.dart'))!.name, 'qux'); + expect(config.toPackageUri(Uri.parse('file:///lib/diz')), + Uri.parse('package:foo/diz')); + expect(config.toPackageUri(Uri.parse('file:///bar/lib/diz')), + Uri.parse('package:bar/diz')); + expect(config.toPackageUri(Uri.parse('file:///bar/baz/lib/diz')), + Uri.parse('package:baz/diz')); + expect(config.toPackageUri(Uri.parse('file:///qux/lib/diz')), + Uri.parse('package:qux/diz')); + }); + + test('packageOf is case sensitive on windows', () { + var configBytes = utf8.encode(json.encode({ + 'configVersion': 2, + 'packages': [ + {'name': 'foo', 'rootUri': 'file:///C:/Foo/', 'packageUri': 'lib/'}, + ] + })); + var config = parsePackageConfigBytes( + // ignore: unnecessary_cast + configBytes as Uint8List, + Uri.parse('file:///C:/tmp/.dart_tool/file.dart'), + throwError); + expect(config.version, 2); + expect( + config.packageOf(Uri.parse('file:///C:/foo/lala/lala.dart')), null); + expect(config.packageOf(Uri.parse('file:///C:/Foo/lala/lala.dart'))!.name, + 'foo'); + }); + + group('invalid', () { + void testThrows(String name, String source) { + test(name, () { + expect( + // ignore: unnecessary_cast + () => parsePackageConfigBytes(utf8.encode(source) as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), throwError), + throwsA(isA())); + }); + } + + void testThrowsContains( + String name, String source, String containsString) { + test(name, () { + dynamic exception; + try { + parsePackageConfigBytes( + // ignore: unnecessary_cast + utf8.encode(source) as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), + throwError, + ); + } catch (e) { + exception = e; + } + if (exception == null) fail("Didn't get exception"); + expect('$exception', contains(containsString)); + }); + } + + testThrows('comment', '# comment\n {$cfg,$pkgs}'); + testThrows('.packages file', 'foo:/foo\n'); + testThrows('no configVersion', '{$pkgs}'); + testThrows('no packages', '{$cfg}'); + group('config version:', () { + testThrows('null', '{"configVersion":null,$pkgs}'); + testThrows('string', '{"configVersion":"2",$pkgs}'); + testThrows('array', '{"configVersion":[2],$pkgs}'); + }); + group('packages:', () { + testThrows('null', '{$cfg,"packages":null}'); + testThrows('string', '{$cfg,"packages":"foo"}'); + testThrows('object', '{$cfg,"packages":{}}'); + }); + group('packages entry:', () { + testThrows('null', '{$cfg,"packages":[null]}'); + testThrows('string', '{$cfg,"packages":["foo"]}'); + testThrows('array', '{$cfg,"packages":[[]]}'); + }); + group('package', () { + testThrows('no name', '{$cfg,"packages":[{$root}]}'); + group('name:', () { + testThrows('null', '{$cfg,"packages":[{"name":null,$root}]}'); + testThrows('num', '{$cfg,"packages":[{"name":1,$root}]}'); + testThrows('object', '{$cfg,"packages":[{"name":{},$root}]}'); + testThrows('empty', '{$cfg,"packages":[{"name":"",$root}]}'); + testThrows('one-dot', '{$cfg,"packages":[{"name":".",$root}]}'); + testThrows('two-dot', '{$cfg,"packages":[{"name":"..",$root}]}'); + testThrows( + "invalid char '\\'", '{$cfg,"packages":[{"name":"\\",$root}]}'); + testThrows( + "invalid char ':'", '{$cfg,"packages":[{"name":":",$root}]}'); + testThrows( + "invalid char ' '", '{$cfg,"packages":[{"name":" ",$root}]}'); + }); + + testThrows('no root', '{$cfg,"packages":[{$name}]}'); + group('root:', () { + testThrows('null', '{$cfg,"packages":[{$name,"rootUri":null}]}'); + testThrows('num', '{$cfg,"packages":[{$name,"rootUri":1}]}'); + testThrows('object', '{$cfg,"packages":[{$name,"rootUri":{}}]}'); + testThrows('fragment', '{$cfg,"packages":[{$name,"rootUri":"x/#"}]}'); + testThrows('query', '{$cfg,"packages":[{$name,"rootUri":"x/?"}]}'); + testThrows('package-URI', + '{$cfg,"packages":[{$name,"rootUri":"package:x/x/"}]}'); + }); + group('package-URI root:', () { + testThrows( + 'null', '{$cfg,"packages":[{$name,$root,"packageUri":null}]}'); + testThrows('num', '{$cfg,"packages":[{$name,$root,"packageUri":1}]}'); + testThrows( + 'object', '{$cfg,"packages":[{$name,$root,"packageUri":{}}]}'); + testThrows('fragment', + '{$cfg,"packages":[{$name,$root,"packageUri":"x/#"}]}'); + testThrows( + 'query', '{$cfg,"packages":[{$name,$root,"packageUri":"x/?"}]}'); + testThrows('package: URI', + '{$cfg,"packages":[{$name,$root,"packageUri":"package:x/x/"}]}'); + testThrows('not inside root', + '{$cfg,"packages":[{$name,$root,"packageUri":"../other/"}]}'); + }); + group('language version', () { + testThrows('null', + '{$cfg,"packages":[{$name,$root,"languageVersion":null}]}'); + testThrows( + 'num', '{$cfg,"packages":[{$name,$root,"languageVersion":1}]}'); + testThrows('object', + '{$cfg,"packages":[{$name,$root,"languageVersion":{}}]}'); + testThrows('empty', + '{$cfg,"packages":[{$name,$root,"languageVersion":""}]}'); + testThrows('non number.number', + '{$cfg,"packages":[{$name,$root,"languageVersion":"x.1"}]}'); + testThrows('number.non number', + '{$cfg,"packages":[{$name,$root,"languageVersion":"1.x"}]}'); + testThrows('non number', + '{$cfg,"packages":[{$name,$root,"languageVersion":"x"}]}'); + testThrows('one number', + '{$cfg,"packages":[{$name,$root,"languageVersion":"1"}]}'); + testThrows('three numbers', + '{$cfg,"packages":[{$name,$root,"languageVersion":"1.2.3"}]}'); + testThrows('leading zero first', + '{$cfg,"packages":[{$name,$root,"languageVersion":"01.1"}]}'); + testThrows('leading zero second', + '{$cfg,"packages":[{$name,$root,"languageVersion":"1.01"}]}'); + testThrows('trailing-', + '{$cfg,"packages":[{$name,$root,"languageVersion":"1.1-1"}]}'); + testThrows('trailing+', + '{$cfg,"packages":[{$name,$root,"languageVersion":"1.1+1"}]}'); + }); + }); + testThrows('duplicate package name', + '{$cfg,"packages":[{$name,$root},{$name,"rootUri":"/other/"}]}'); + testThrowsContains( + // The roots of foo and bar are the same. + 'same roots', + '{$cfg,"packages":[{$name,$root},{"name":"bar",$root}]}', + 'the same root directory'); + testThrowsContains( + // The roots of foo and bar are the same. + 'same roots 2', + '{$cfg,"packages":[{$name,"rootUri":"/"},{"name":"bar","rootUri":"/"}]}', + 'the same root directory'); + testThrowsContains( + // The root of bar is inside the root of foo, + // but the package root of foo is inside the root of bar. + 'between root and lib', + '{$cfg,"packages":[' + '{"name":"foo","rootUri":"/foo/","packageUri":"bar/lib/"},' + '{"name":"bar","rootUri":"/foo/bar/","packageUri":"baz/lib"}]}', + 'package root of foo is inside the root of bar'); + + // This shouldn't be allowed, but for internal reasons it is. + test('package inside package root', () { + var config = parsePackageConfigBytes( + // ignore: unnecessary_cast + utf8.encode( + '{$cfg,"packages":[' + '{"name":"foo","rootUri":"/foo/","packageUri":"lib/"},' + '{"name":"bar","rootUri":"/foo/lib/bar/","packageUri":"lib"}]}', + ) as Uint8List, + Uri.parse('file:///tmp/.dart_tool/file.dart'), + throwError); + expect( + config + .packageOf(Uri.parse('file:///foo/lib/bar/lib/lala.dart'))! + .name, + 'foo'); // why not bar? + expect(config.toPackageUri(Uri.parse('file:///foo/lib/bar/lib/diz')), + Uri.parse('package:foo/bar/lib/diz')); // why not package:bar/diz? + }); + }); + }); + + group('factories', () { + void testConfig(String name, PackageConfig config, PackageConfig expected) { + group(name, () { + test('structure', () { + expect(config.version, expected.version); + var expectedPackages = {for (var p in expected.packages) p.name}; + var actualPackages = {for (var p in config.packages) p.name}; + expect(actualPackages, expectedPackages); + }); + for (var package in config.packages) { + var name = package.name; + test('package $name', () { + var expectedPackage = expected[name]!; + expect(expectedPackage, isNotNull); + expect(package.root, expectedPackage.root, reason: 'root'); + expect(package.packageUriRoot, expectedPackage.packageUriRoot, + reason: 'package root'); + expect(package.languageVersion, expectedPackage.languageVersion, + reason: 'languageVersion'); + }); + } + }); + } + + var configText = ''' + {"configVersion": 2, "packages": [ + { + "name": "foo", + "rootUri": "foo/", + "packageUri": "bar/", + "languageVersion": "1.2" + } + ]} + '''; + var baseUri = Uri.parse('file:///start/'); + var config = PackageConfig([ + Package('foo', Uri.parse('file:///start/foo/'), + packageUriRoot: Uri.parse('file:///start/foo/bar/'), + languageVersion: LanguageVersion(1, 2)) + ]); + testConfig( + 'string', PackageConfig.parseString(configText, baseUri), config); + testConfig( + 'bytes', + PackageConfig.parseBytes( + Uint8List.fromList(configText.codeUnits), baseUri), + config); + testConfig('json', PackageConfig.parseJson(jsonDecode(configText), baseUri), + config); + + baseUri = Uri.parse('file:///start2/'); + config = PackageConfig([ + Package('foo', Uri.parse('file:///start2/foo/'), + packageUriRoot: Uri.parse('file:///start2/foo/bar/'), + languageVersion: LanguageVersion(1, 2)) + ]); + testConfig( + 'string2', PackageConfig.parseString(configText, baseUri), config); + testConfig( + 'bytes2', + PackageConfig.parseBytes( + Uint8List.fromList(configText.codeUnits), baseUri), + config); + testConfig('json2', + PackageConfig.parseJson(jsonDecode(configText), baseUri), config); + }); +} diff --git a/pkgs/package_config/test/src/util.dart b/pkgs/package_config/test/src/util.dart new file mode 100644 index 000000000..780ee80dc --- /dev/null +++ b/pkgs/package_config/test/src/util.dart @@ -0,0 +1,57 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:test/test.dart'; + +/// Creates a package: URI. +Uri pkg(String packageName, String packagePath) { + var path = + "$packageName${packagePath.startsWith('/') ? "" : "/"}$packagePath"; + return Uri(scheme: 'package', path: path); +} + +// Remove if not used. +String configFromPackages(List> packages) => """ +{ + "configVersion": 2, + "packages": [ +${packages.map((nu) => """ + { + "name": "${nu[0]}", + "rootUri": "${nu[1]}" + }""").join(",\n")} + ] +} +"""; + +/// Mimics a directory structure of [description] and runs [loaderTest]. +/// +/// Description is a map, each key is a file entry. If the value is a map, +/// it's a subdirectory, otherwise it's a file and the value is the content +/// as a string. +void loaderTest( + String name, + Map description, + void Function(Uri root, Future Function(Uri) loader) loaderTest, +) { + var root = Uri(scheme: 'test', path: '/'); + Future loader(Uri uri) async { + var path = uri.path; + if (!uri.isScheme('test') || !path.startsWith('/')) return null; + var parts = path.split('/'); + Object? value = description; + for (var i = 1; i < parts.length; i++) { + if (value is! Map) return null; + value = value[parts[i]]; + } + // ignore: unnecessary_cast + if (value is String) return utf8.encode(value) as Uint8List; + return null; + } + + test(name, () => loaderTest(root, loader)); +} diff --git a/pkgs/package_config/test/src/util_io.dart b/pkgs/package_config/test/src/util_io.dart new file mode 100644 index 000000000..e032556f4 --- /dev/null +++ b/pkgs/package_config/test/src/util_io.dart @@ -0,0 +1,62 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:io'; + +import 'package:package_config/src/util_io.dart'; +import 'package:test/test.dart'; + +/// Creates a directory structure from [description] and runs [fileTest]. +/// +/// Description is a map, each key is a file entry. If the value is a map, +/// it's a subdirectory, otherwise it's a file and the value is the content +/// as a string. +/// Introduces a group to hold the [setUp]/[tearDown] logic. +void fileTest(String name, Map description, + void Function(Directory directory) fileTest) { + group('file-test', () { + var tempDir = Directory.systemTemp.createTempSync('pkgcfgtest'); + setUp(() { + _createFiles(tempDir, description); + }); + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + test(name, () => fileTest(tempDir)); + }); +} + +/// Creates a set of files under a new temporary directory. +/// Returns the temporary directory. +/// +/// The [description] is a map from file names to content. +/// If the content is again a map, it represents a subdirectory +/// with the content as description. +/// Otherwise the content should be a string, +/// which is written to the file as UTF-8. +// Directory createTestFiles(Map description) { +// var target = Directory.systemTemp.createTempSync("pkgcfgtest"); +// _createFiles(target, description); +// return target; +// } + +// Creates temporary files in the target directory. +void _createFiles(Directory target, Map description) { + description.forEach((name, content) { + var entryName = pathJoin(target.path, '$name'); + if (content is Map) { + _createFiles(Directory(entryName)..createSync(), content); + } else { + File(entryName).writeAsStringSync(content as String, flush: true); + } + }); +} + +/// Creates a [Directory] for a subdirectory of [parent]. +Directory subdir(Directory parent, String dirName) => + Directory(pathJoinAll([parent.path, ...dirName.split('/')])); + +/// Creates a [File] for an entry in the [directory] directory. +File dirFile(Directory directory, String fileName) => + File(pathJoin(directory.path, fileName)); diff --git a/pkgs/pool/.gitignore b/pkgs/pool/.gitignore new file mode 100644 index 000000000..e450c836d --- /dev/null +++ b/pkgs/pool/.gitignore @@ -0,0 +1,5 @@ +# Don’t commit the following directories created by pub. +.dart_tool/ +.packages +.pub/ +pubspec.lock diff --git a/pkgs/pool/CHANGELOG.md b/pkgs/pool/CHANGELOG.md new file mode 100644 index 000000000..56424fc6f --- /dev/null +++ b/pkgs/pool/CHANGELOG.md @@ -0,0 +1,105 @@ +## 1.5.2-wip + +* Require Dart 3.4. +* Move to `dart-lang/tools` monorepo. + +## 1.5.1 + +* Populate the pubspec `repository` field. + +## 1.5.0 + +* Stable release for null safety. + +## 1.5.0-nullsafety.3 + +* Update SDK constraints to `>=2.12.0-0 <3.0.0` based on beta release + guidelines. + +## 1.5.0-nullsafety.2 + +* Allow prerelease versions of the 2.12 sdk. + +## 1.5.0-nullsafety.1 + +* Allow 2.10 stable and 2.11.0 dev SDK versions. + +## 1.5.0-nullsafety + +* Migrate to null safety. +* `forEach`: Avoid `await null` if the `Stream` is not paused. + Improves trivial benchmark by 40%. + +## 1.4.0 + +* Add `forEach` to `Pool` to support efficient async processing of an + `Iterable`. + +* Throw ArgumentError if poolSize <= 0 + +## 1.3.6 + +* Set max SDK version to `<3.0.0`, and adjust other dependencies. + +## 1.3.5 + +- Updated SDK version to 2.0.0-dev.17.0 + +## 1.3.4 + +* Modify code to eliminate Future flattening. + +## 1.3.3 + +* Declare support for `async` 2.0.0. + +## 1.3.2 + +* Update to make the code work with strong-mode clean Zone API. + +* Required minimum SDK of 1.23.0. + +## 1.3.1 + +* Fix the type annotation of `Pool.withResource()` to indicate that it takes + `() -> FutureOr`. + +## 1.3.0 + +* Add a `Pool.done` getter that returns the same future returned by + `Pool.close()`. + +## 1.2.4 + +* Fix a strong-mode error. + +## 1.2.3 + +* Fix a bug in which `Pool.withResource()` could throw a `StateError` when + called immediately before closing the pool. + +## 1.2.2 + +* Fix strong mode warnings and add generic method annotations. + +## 1.2.1 + +* Internal changes only. + +## 1.2.0 + +* Add `Pool.close()`, which forbids new resource requests and releases all + releasable resources. + +## 1.1.0 + +* Add `PoolResource.allowRelease()`, which allows a resource to indicate that it + can be released without forcing it to deallocate immediately. + +## 1.0.2 + +* Fixed the homepage. + +## 1.0.1 + +* A `TimeoutException` is now correctly thrown if the pool detects a deadlock. diff --git a/pkgs/pool/LICENSE b/pkgs/pool/LICENSE new file mode 100644 index 000000000..000cd7bec --- /dev/null +++ b/pkgs/pool/LICENSE @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/pool/README.md b/pkgs/pool/README.md new file mode 100644 index 000000000..461e872b8 --- /dev/null +++ b/pkgs/pool/README.md @@ -0,0 +1,57 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/pool.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/pool.yaml) +[![pub package](https://img.shields.io/pub/v/pool.svg)](https://pub.dev/packages/pool) +[![package publisher](https://img.shields.io/pub/publisher/pool.svg)](https://pub.dev/packages/pool/publisher) + +The pool package exposes a `Pool` class which makes it easy to manage a limited +pool of resources. + +The easiest way to use a pool is by calling `withResource`. This runs a callback +and returns its result, but only once there aren't too many other callbacks +currently running. + +```dart +// Create a Pool that will only allocate 10 resources at once. After 30 seconds +// of inactivity with all resources checked out, the pool will throw an error. +final pool = new Pool(10, timeout: new Duration(seconds: 30)); + +Future readFile(String path) { + // Since the call to [File.readAsString] is within [withResource], no more + // than ten files will be open at once. + return pool.withResource(() => new File(path).readAsString()); +} +``` + +For more fine-grained control, the user can also explicitly request generic +`PoolResource` objects that can later be released back into the pool. This is +what `withResource` does under the covers: requests a resource, then releases it +once the callback completes. + +`Pool` ensures that only a limited number of resources are allocated at once. +It's the caller's responsibility to ensure that the corresponding physical +resource is only consumed when a `PoolResource` is allocated. + +```dart +class PooledFile implements RandomAccessFile { + final RandomAccessFile _file; + final PoolResource _resource; + + static Future open(String path) { + return pool.request().then((resource) { + return new File(path).open().then((file) { + return new PooledFile._(file, resource); + }); + }); + } + + PooledFile(this._file, this._resource); + + // ... + + Future close() { + return _file.close.then((_) { + _resource.release(); + return this; + }); + } +} +``` diff --git a/pkgs/pool/analysis_options.yaml b/pkgs/pool/analysis_options.yaml new file mode 100644 index 000000000..44cda4da2 --- /dev/null +++ b/pkgs/pool/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true diff --git a/pkgs/pool/benchmark/for_each_benchmark.dart b/pkgs/pool/benchmark/for_each_benchmark.dart new file mode 100644 index 000000000..0cd2543e2 --- /dev/null +++ b/pkgs/pool/benchmark/for_each_benchmark.dart @@ -0,0 +1,55 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:pool/pool.dart'; + +void main(List args) async { + var poolSize = args.isEmpty ? 5 : int.parse(args.first); + print('Pool size: $poolSize'); + + final pool = Pool(poolSize); + final watch = Stopwatch()..start(); + final start = DateTime.now(); + + DateTime? lastLog; + Duration? fastest; + late int fastestIteration; + var i = 1; + + void log(bool force) { + var now = DateTime.now(); + if (force || + lastLog == null || + now.difference(lastLog!) > const Duration(seconds: 1)) { + lastLog = now; + print([ + now.difference(start), + i.toString().padLeft(10), + fastestIteration.toString().padLeft(7), + fastest!.inMicroseconds.toString().padLeft(9) + ].join(' ')); + } + } + + print(['Elapsed ', 'Iterations', 'Fastest', 'Time (us)'].join(' ')); + + for (;; i++) { + watch.reset(); + + var sum = await pool + .forEach(Iterable.generate(100000), (i) => i) + .reduce((a, b) => a + b); + + assert(sum == 4999950000, 'was $sum'); + + var elapsed = watch.elapsed; + if (fastest == null || fastest > elapsed) { + fastest = elapsed; + fastestIteration = i; + log(true); + } else { + log(false); + } + } +} diff --git a/pkgs/pool/lib/pool.dart b/pkgs/pool/lib/pool.dart new file mode 100644 index 000000000..70e9df158 --- /dev/null +++ b/pkgs/pool/lib/pool.dart @@ -0,0 +1,380 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:collection'; + +import 'package:async/async.dart'; +import 'package:stack_trace/stack_trace.dart'; + +/// Manages an abstract pool of resources with a limit on how many may be in use +/// at once. +/// +/// When a resource is needed, the user should call [request]. When the returned +/// future completes with a [PoolResource], the resource may be allocated. Once +/// the resource has been released, the user should call [PoolResource.release]. +/// The pool will ensure that only a certain number of [PoolResource]s may be +/// allocated at once. +class Pool { + /// Completers for requests beyond the first [_maxAllocatedResources]. + /// + /// When an item is released, the next element of [_requestedResources] will + /// be completed. + final _requestedResources = Queue>(); + + /// Callbacks that must be called before additional resources can be + /// allocated. + /// + /// See [PoolResource.allowRelease]. + final _onReleaseCallbacks = Queue(); + + /// Completers that will be completed once `onRelease` callbacks are done + /// running. + /// + /// These are kept in a queue to ensure that the earliest request completes + /// first regardless of what order the `onRelease` callbacks complete in. + final _onReleaseCompleters = Queue>(); + + /// The maximum number of resources that may be allocated at once. + final int _maxAllocatedResources; + + /// The number of resources that are currently allocated. + int _allocatedResources = 0; + + /// The timeout timer. + /// + /// This timer is canceled as long as the pool is below the resource limit. + /// It's reset once the resource limit is reached and again every time an + /// resource is released or a new resource is requested. If it fires, that + /// indicates that the caller became deadlocked, likely due to files waiting + /// for additional files to be read before they could be closed. + /// + /// This is `null` if this pool shouldn't time out. + RestartableTimer? _timer; + + /// The amount of time to wait before timing out the pending resources. + final Duration? _timeout; + + /// A [FutureGroup] that tracks all the `onRelease` callbacks for resources + /// that have been marked releasable. + /// + /// This is `null` until [close] is called. + FutureGroup? _closeGroup; + + /// Whether [close] has been called. + bool get isClosed => _closeMemo.hasRun; + + /// A future that completes once the pool is closed and all its outstanding + /// resources have been released. + /// + /// If any [PoolResource.allowRelease] callback throws an exception after the + /// pool is closed, this completes with that exception. + Future get done => _closeMemo.future; + + /// Creates a new pool with the given limit on how many resources may be + /// allocated at once. + /// + /// If [timeout] is passed, then if that much time passes without any activity + /// all pending [request] futures will throw a [TimeoutException]. This is + /// intended to avoid deadlocks. + Pool(this._maxAllocatedResources, {Duration? timeout}) : _timeout = timeout { + if (_maxAllocatedResources <= 0) { + throw ArgumentError.value(_maxAllocatedResources, 'maxAllocatedResources', + 'Must be greater than zero.'); + } + + if (timeout != null) { + // Start the timer canceled since we only want to start counting down once + // we've run out of available resources. + _timer = RestartableTimer(timeout, _onTimeout)..cancel(); + } + } + + /// Request a [PoolResource]. + /// + /// If the maximum number of resources is already allocated, this will delay + /// until one of them is released. + Future request() { + if (isClosed) { + throw StateError('request() may not be called on a closed Pool.'); + } + + if (_allocatedResources < _maxAllocatedResources) { + _allocatedResources++; + return Future.value(PoolResource._(this)); + } else if (_onReleaseCallbacks.isNotEmpty) { + return _runOnRelease(_onReleaseCallbacks.removeFirst()); + } else { + var completer = Completer(); + _requestedResources.add(completer); + _resetTimer(); + return completer.future; + } + } + + /// Requests a resource for the duration of [callback], which may return a + /// Future. + /// + /// The return value of [callback] is piped to the returned Future. + Future withResource(FutureOr Function() callback) async { + if (isClosed) { + throw StateError('withResource() may not be called on a closed Pool.'); + } + + var resource = await request(); + try { + return await callback(); + } finally { + resource.release(); + } + } + + /// Returns a [Stream] containing the result of [action] applied to each + /// element of [elements]. + /// + /// While [action] is invoked on each element of [elements] in order, + /// it's possible the return [Stream] may have items out-of-order – especially + /// if the completion time of [action] varies. + /// + /// If [action] throws an error the source item along with the error object + /// and [StackTrace] are passed to [onError], if it is provided. If [onError] + /// returns `true`, the error is added to the returned [Stream], otherwise + /// it is ignored. + /// + /// Errors thrown from iterating [elements] will not be passed to + /// [onError]. They will always be added to the returned stream as an error. + /// + /// Note: all of the resources of the this [Pool] will be used when the + /// returned [Stream] is listened to until it is completed or canceled. + /// + /// Note: if this [Pool] is closed before the returned [Stream] is listened + /// to, a [StateError] is thrown. + Stream forEach( + Iterable elements, FutureOr Function(S source) action, + {bool Function(S item, Object error, StackTrace stack)? onError}) { + onError ??= (item, e, s) => true; + + var cancelPending = false; + + Completer? resumeCompleter; + late StreamController controller; + + late Iterator iterator; + + Future run(int _) async { + while (iterator.moveNext()) { + // caching `current` is necessary because there are async breaks + // in this code and `iterator` is shared across many workers + final current = iterator.current; + + _resetTimer(); + + if (resumeCompleter != null) { + await resumeCompleter!.future; + } + + if (cancelPending) { + break; + } + + T value; + try { + value = await action(current); + } catch (e, stack) { + if (onError!(current, e, stack)) { + controller.addError(e, stack); + } + continue; + } + controller.add(value); + } + } + + Future? doneFuture; + + void onListen() { + iterator = elements.iterator; + + assert(doneFuture == null); + var futures = Iterable>.generate( + _maxAllocatedResources, (i) => withResource(() => run(i))); + doneFuture = Future.wait(futures, eagerError: true) + .then((_) {}) + .catchError(controller.addError); + + doneFuture!.whenComplete(controller.close); + } + + controller = StreamController( + sync: true, + onListen: onListen, + onCancel: () async { + assert(!cancelPending); + cancelPending = true; + await doneFuture; + }, + onPause: () { + assert(resumeCompleter == null); + resumeCompleter = Completer(); + }, + onResume: () { + assert(resumeCompleter != null); + resumeCompleter!.complete(); + resumeCompleter = null; + }, + ); + + return controller.stream; + } + + /// Closes the pool so that no more resources are requested. + /// + /// Existing resource requests remain unchanged. + /// + /// Any resources that are marked as releasable using + /// [PoolResource.allowRelease] are released immediately. Once all resources + /// have been released and any `onRelease` callbacks have completed, the + /// returned future completes successfully. If any `onRelease` callback throws + /// an error, the returned future completes with that error. + /// + /// This may be called more than once; it returns the same [Future] each time. + Future close() => _closeMemo.runOnce(_close); + + Future _close() { + if (_closeGroup != null) return _closeGroup!.future; + + _resetTimer(); + + _closeGroup = FutureGroup(); + for (var callback in _onReleaseCallbacks) { + _closeGroup!.add(Future.sync(callback)); + } + + _allocatedResources -= _onReleaseCallbacks.length; + _onReleaseCallbacks.clear(); + + if (_allocatedResources == 0) _closeGroup!.close(); + return _closeGroup!.future; + } + + final _closeMemo = AsyncMemoizer(); + + /// If there are any pending requests, this will fire the oldest one. + void _onResourceReleased() { + _resetTimer(); + + if (_requestedResources.isNotEmpty) { + var pending = _requestedResources.removeFirst(); + pending.complete(PoolResource._(this)); + } else { + _allocatedResources--; + if (isClosed && _allocatedResources == 0) _closeGroup!.close(); + } + } + + /// If there are any pending requests, this will fire the oldest one after + /// running [onRelease]. + void _onResourceReleaseAllowed(void Function() onRelease) { + _resetTimer(); + + if (_requestedResources.isNotEmpty) { + var pending = _requestedResources.removeFirst(); + pending.complete(_runOnRelease(onRelease)); + } else if (isClosed) { + _closeGroup!.add(Future.sync(onRelease)); + _allocatedResources--; + if (_allocatedResources == 0) _closeGroup!.close(); + } else { + var zone = Zone.current; + var registered = zone.registerCallback(onRelease); + _onReleaseCallbacks.add(() => zone.run(registered)); + } + } + + /// Runs [onRelease] and returns a Future that completes to a resource once an + /// [onRelease] callback completes. + /// + /// Futures returned by [_runOnRelease] always complete in the order they were + /// created, even if earlier [onRelease] callbacks take longer to run. + Future _runOnRelease(void Function() onRelease) { + Future.sync(onRelease).then((value) { + _onReleaseCompleters.removeFirst().complete(PoolResource._(this)); + }).catchError((Object error, StackTrace stackTrace) { + _onReleaseCompleters.removeFirst().completeError(error, stackTrace); + }); + + var completer = Completer.sync(); + _onReleaseCompleters.add(completer); + return completer.future; + } + + /// A resource has been requested, allocated, or released. + void _resetTimer() { + if (_timer == null) return; + + if (_requestedResources.isEmpty) { + _timer!.cancel(); + } else { + _timer!.reset(); + } + } + + /// Handles [_timer] timing out by causing all pending resource completers to + /// emit exceptions. + void _onTimeout() { + for (var completer in _requestedResources) { + completer.completeError( + TimeoutException( + 'Pool deadlock: all resources have been ' + 'allocated for too long.', + _timeout), + Chain.current()); + } + _requestedResources.clear(); + _timer = null; + } +} + +/// A member of a [Pool]. +/// +/// A [PoolResource] is a token that indicates that a resource is allocated. +/// When the associated resource is released, the user should call [release]. +class PoolResource { + final Pool _pool; + + /// Whether `this` has been released yet. + bool _released = false; + + PoolResource._(this._pool); + + /// Tells the parent [Pool] that the resource associated with this resource is + /// no longer allocated, and that a new [PoolResource] may be allocated. + void release() { + if (_released) { + throw StateError('A PoolResource may only be released once.'); + } + _released = true; + _pool._onResourceReleased(); + } + + /// Tells the parent [Pool] that the resource associated with this resource is + /// no longer necessary, but should remain allocated until more resources are + /// needed. + /// + /// When [Pool.request] is called and there are no remaining available + /// resources, the [onRelease] callback is called. It should free the + /// resource, and it may return a Future or `null`. Once that completes, the + /// [Pool.request] call will complete to a new [PoolResource]. + /// + /// This is useful when a resource's main function is complete, but it may + /// produce additional information later on. For example, an isolate's task + /// may be complete, but it could still emit asynchronous errors. + void allowRelease(FutureOr Function() onRelease) { + if (_released) { + throw StateError('A PoolResource may only be released once.'); + } + _released = true; + _pool._onResourceReleaseAllowed(onRelease); + } +} diff --git a/pkgs/pool/pubspec.yaml b/pkgs/pool/pubspec.yaml new file mode 100644 index 000000000..a205b7494 --- /dev/null +++ b/pkgs/pool/pubspec.yaml @@ -0,0 +1,18 @@ +name: pool +version: 1.5.2-wip +description: >- + Manage a finite pool of resources. + Useful for controlling concurrent file system or network requests. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/pool + +environment: + sdk: ^3.4.0 + +dependencies: + async: ^2.5.0 + stack_trace: ^1.10.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + fake_async: ^1.2.0 + test: ^1.16.6 diff --git a/pkgs/pool/test/pool_test.dart b/pkgs/pool/test/pool_test.dart new file mode 100644 index 000000000..6334a8abd --- /dev/null +++ b/pkgs/pool/test/pool_test.dart @@ -0,0 +1,745 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:pool/pool.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/test.dart'; + +void main() { + group('request()', () { + test('resources can be requested freely up to the limit', () { + var pool = Pool(50); + for (var i = 0; i < 50; i++) { + expect(pool.request(), completes); + } + }); + + test('resources block past the limit', () { + FakeAsync().run((async) { + var pool = Pool(50); + for (var i = 0; i < 50; i++) { + expect(pool.request(), completes); + } + expect(pool.request(), doesNotComplete); + + async.elapse(const Duration(seconds: 1)); + }); + }); + + test('a blocked resource is allocated when another is released', () { + FakeAsync().run((async) { + var pool = Pool(50); + for (var i = 0; i < 49; i++) { + expect(pool.request(), completes); + } + + pool.request().then((lastAllocatedResource) { + // This will only complete once [lastAllocatedResource] is released. + expect(pool.request(), completes); + + Future.delayed(const Duration(microseconds: 1)).then((_) { + lastAllocatedResource.release(); + }); + }); + + async.elapse(const Duration(seconds: 1)); + }); + }); + }); + + group('withResource()', () { + test('can be called freely up to the limit', () { + var pool = Pool(50); + for (var i = 0; i < 50; i++) { + pool.withResource(expectAsync0(() => Completer().future)); + } + }); + + test('blocks the callback past the limit', () { + FakeAsync().run((async) { + var pool = Pool(50); + for (var i = 0; i < 50; i++) { + pool.withResource(expectAsync0(() => Completer().future)); + } + pool.withResource(expectNoAsync()); + + async.elapse(const Duration(seconds: 1)); + }); + }); + + test('a blocked resource is allocated when another is released', () { + FakeAsync().run((async) { + var pool = Pool(50); + for (var i = 0; i < 49; i++) { + pool.withResource(expectAsync0(() => Completer().future)); + } + + var completer = Completer(); + pool.withResource(() => completer.future); + var blockedResourceAllocated = false; + pool.withResource(() { + blockedResourceAllocated = true; + }); + + Future.delayed(const Duration(microseconds: 1)).then((_) { + expect(blockedResourceAllocated, isFalse); + completer.complete(); + return Future.delayed(const Duration(microseconds: 1)); + }).then((_) { + expect(blockedResourceAllocated, isTrue); + }); + + async.elapse(const Duration(seconds: 1)); + }); + }); + + // Regression test for #3. + test('can be called immediately before close()', () async { + var pool = Pool(1); + unawaited(pool.withResource(expectAsync0(() {}))); + await pool.close(); + }); + }); + + group('with a timeout', () { + test("doesn't time out if there are no pending requests", () { + FakeAsync().run((async) { + var pool = Pool(50, timeout: const Duration(seconds: 5)); + for (var i = 0; i < 50; i++) { + expect(pool.request(), completes); + } + + async.elapse(const Duration(seconds: 6)); + }); + }); + + test('resets the timer if a resource is returned', () { + FakeAsync().run((async) { + var pool = Pool(50, timeout: const Duration(seconds: 5)); + for (var i = 0; i < 49; i++) { + expect(pool.request(), completes); + } + + pool.request().then((lastAllocatedResource) { + // This will only complete once [lastAllocatedResource] is released. + expect(pool.request(), completes); + + Future.delayed(const Duration(seconds: 3)).then((_) { + lastAllocatedResource.release(); + expect(pool.request(), doesNotComplete); + }); + }); + + async.elapse(const Duration(seconds: 6)); + }); + }); + + test('resets the timer if a resource is requested', () { + FakeAsync().run((async) { + var pool = Pool(50, timeout: const Duration(seconds: 5)); + for (var i = 0; i < 50; i++) { + expect(pool.request(), completes); + } + expect(pool.request(), doesNotComplete); + + Future.delayed(const Duration(seconds: 3)).then((_) { + expect(pool.request(), doesNotComplete); + }); + + async.elapse(const Duration(seconds: 6)); + }); + }); + + test('times out if nothing happens', () { + FakeAsync().run((async) { + var pool = Pool(50, timeout: const Duration(seconds: 5)); + for (var i = 0; i < 50; i++) { + expect(pool.request(), completes); + } + expect(pool.request(), throwsA(const TypeMatcher())); + + async.elapse(const Duration(seconds: 6)); + }); + }); + }); + + group('allowRelease()', () { + test('runs the callback once the resource limit is exceeded', () async { + var pool = Pool(50); + for (var i = 0; i < 49; i++) { + expect(pool.request(), completes); + } + + var resource = await pool.request(); + var onReleaseCalled = false; + resource.allowRelease(() => onReleaseCalled = true); + await Future.delayed(Duration.zero); + expect(onReleaseCalled, isFalse); + + expect(pool.request(), completes); + await Future.delayed(Duration.zero); + expect(onReleaseCalled, isTrue); + }); + + test('runs the callback immediately if there are blocked requests', + () async { + var pool = Pool(1); + var resource = await pool.request(); + + // This will be blocked until [resource.allowRelease] is called. + expect(pool.request(), completes); + + var onReleaseCalled = false; + resource.allowRelease(() => onReleaseCalled = true); + await Future.delayed(Duration.zero); + expect(onReleaseCalled, isTrue); + }); + + test('blocks the request until the callback completes', () async { + var pool = Pool(1); + var resource = await pool.request(); + + var requestComplete = false; + unawaited(pool.request().then((_) => requestComplete = true)); + + var completer = Completer(); + resource.allowRelease(() => completer.future); + await Future.delayed(Duration.zero); + expect(requestComplete, isFalse); + + completer.complete(); + await Future.delayed(Duration.zero); + expect(requestComplete, isTrue); + }); + + test('completes requests in request order regardless of callback order', + () async { + var pool = Pool(2); + var resource1 = await pool.request(); + var resource2 = await pool.request(); + + var request1Complete = false; + unawaited(pool.request().then((_) => request1Complete = true)); + var request2Complete = false; + unawaited(pool.request().then((_) => request2Complete = true)); + + var onRelease1Called = false; + var completer1 = Completer(); + resource1.allowRelease(() { + onRelease1Called = true; + return completer1.future; + }); + await Future.delayed(Duration.zero); + expect(onRelease1Called, isTrue); + + var onRelease2Called = false; + var completer2 = Completer(); + resource2.allowRelease(() { + onRelease2Called = true; + return completer2.future; + }); + await Future.delayed(Duration.zero); + expect(onRelease2Called, isTrue); + expect(request1Complete, isFalse); + expect(request2Complete, isFalse); + + // Complete the second resource's onRelease callback first. Even though it + // was triggered by the second blocking request, it should complete the + // first one to preserve ordering. + completer2.complete(); + await Future.delayed(Duration.zero); + expect(request1Complete, isTrue); + expect(request2Complete, isFalse); + + completer1.complete(); + await Future.delayed(Duration.zero); + expect(request1Complete, isTrue); + expect(request2Complete, isTrue); + }); + + test('runs onRequest in the zone it was created', () async { + var pool = Pool(1); + var resource = await pool.request(); + + var outerZone = Zone.current; + runZoned(() { + var innerZone = Zone.current; + expect(innerZone, isNot(equals(outerZone))); + + resource.allowRelease(expectAsync0(() { + expect(Zone.current, equals(innerZone)); + })); + }); + + await pool.request(); + }); + }); + + test("done doesn't complete without close", () async { + var pool = Pool(1); + unawaited(pool.done.then(expectAsync1((_) {}, count: 0))); + + var resource = await pool.request(); + resource.release(); + + await Future.delayed(Duration.zero); + }); + + group('close()', () { + test('disallows request() and withResource()', () { + var pool = Pool(1)..close(); + expect(pool.request, throwsStateError); + expect(() => pool.withResource(() {}), throwsStateError); + }); + + test('pending requests are fulfilled', () async { + var pool = Pool(1); + var resource1 = await pool.request(); + expect( + pool.request().then((resource2) { + resource2.release(); + }), + completes); + expect(pool.done, completes); + expect(pool.close(), completes); + resource1.release(); + }); + + test('pending requests are fulfilled with allowRelease', () async { + var pool = Pool(1); + var resource1 = await pool.request(); + + var completer = Completer(); + expect( + pool.request().then((resource2) { + expect(completer.isCompleted, isTrue); + resource2.release(); + }), + completes); + expect(pool.close(), completes); + + resource1.allowRelease(() => completer.future); + await Future.delayed(Duration.zero); + + completer.complete(); + }); + + test("doesn't complete until all resources are released", () async { + var pool = Pool(2); + var resource1 = await pool.request(); + var resource2 = await pool.request(); + var resource3Future = pool.request(); + + var resource1Released = false; + var resource2Released = false; + var resource3Released = false; + expect( + pool.close().then((_) { + expect(resource1Released, isTrue); + expect(resource2Released, isTrue); + expect(resource3Released, isTrue); + }), + completes); + + resource1Released = true; + resource1.release(); + await Future.delayed(Duration.zero); + + resource2Released = true; + resource2.release(); + await Future.delayed(Duration.zero); + + var resource3 = await resource3Future; + resource3Released = true; + resource3.release(); + }); + + test('active onReleases complete as usual', () async { + var pool = Pool(1); + var resource = await pool.request(); + + // Set up an onRelease callback whose completion is controlled by + // [completer]. + var completer = Completer(); + resource.allowRelease(() => completer.future); + expect( + pool.request().then((_) { + expect(completer.isCompleted, isTrue); + }), + completes); + + await Future.delayed(Duration.zero); + unawaited(pool.close()); + + await Future.delayed(Duration.zero); + completer.complete(); + }); + + test('inactive onReleases fire', () async { + var pool = Pool(2); + var resource1 = await pool.request(); + var resource2 = await pool.request(); + + var completer1 = Completer(); + resource1.allowRelease(() => completer1.future); + var completer2 = Completer(); + resource2.allowRelease(() => completer2.future); + + expect( + pool.close().then((_) { + expect(completer1.isCompleted, isTrue); + expect(completer2.isCompleted, isTrue); + }), + completes); + + await Future.delayed(Duration.zero); + completer1.complete(); + + await Future.delayed(Duration.zero); + completer2.complete(); + }); + + test('new allowReleases fire immediately', () async { + var pool = Pool(1); + var resource = await pool.request(); + + var completer = Completer(); + expect( + pool.close().then((_) { + expect(completer.isCompleted, isTrue); + }), + completes); + + await Future.delayed(Duration.zero); + resource.allowRelease(() => completer.future); + + await Future.delayed(Duration.zero); + completer.complete(); + }); + + test('an onRelease error is piped to the return value', () async { + var pool = Pool(1); + var resource = await pool.request(); + + var completer = Completer(); + resource.allowRelease(() => completer.future); + + expect(pool.done, throwsA('oh no!')); + expect(pool.close(), throwsA('oh no!')); + + await Future.delayed(Duration.zero); + completer.completeError('oh no!'); + }); + }); + + group('forEach', () { + late Pool pool; + + tearDown(() async { + await pool.close(); + }); + + const delayedToStringDuration = Duration(milliseconds: 10); + + Future delayedToString(int i) => + Future.delayed(delayedToStringDuration, () => i.toString()); + + for (var itemCount in [0, 5]) { + for (var poolSize in [1, 5, 6]) { + test('poolSize: $poolSize, itemCount: $itemCount', () async { + pool = Pool(poolSize); + + var finishedItems = 0; + + await for (var item in pool.forEach( + Iterable.generate(itemCount, (i) { + expect(i, lessThanOrEqualTo(finishedItems + poolSize), + reason: 'the iterator should be called lazily'); + return i; + }), + delayedToString)) { + expect(int.parse(item), lessThan(itemCount)); + finishedItems++; + } + + expect(finishedItems, itemCount); + }); + } + } + + test('pool closed before listen', () async { + pool = Pool(2); + + var stream = pool.forEach(Iterable.generate(5), delayedToString); + + await pool.close(); + + expect(stream.toList(), throwsStateError); + }); + + test('completes even if the pool is partially used', () async { + pool = Pool(2); + + var resource = await pool.request(); + + var stream = pool.forEach([], delayedToString); + + expect(await stream.length, 0); + + resource.release(); + }); + + test('stream paused longer than timeout', () async { + pool = Pool(2, timeout: delayedToStringDuration); + + var resource = await pool.request(); + + var stream = pool.forEach( + Iterable.generate(100, (i) { + expect(i, lessThan(20), + reason: 'The timeout should happen ' + 'before the entire iterable is iterated.'); + return i; + }), (i) async { + await Future.delayed(Duration(milliseconds: i)); + return i; + }); + + await expectLater( + stream.toList, + throwsA(const TypeMatcher().having( + (te) => te.message, + 'message', + contains('Pool deadlock: ' + 'all resources have been allocated for too long.')))); + + resource.release(); + }); + + group('timing and timeout', () { + for (var poolSize in [2, 8, 64]) { + for (var otherTaskCount + in [0, 1, 7, 63].where((otc) => otc < poolSize)) { + test('poolSize: $poolSize, otherTaskCount: $otherTaskCount', + () async { + final itemCount = 128; + pool = Pool(poolSize, timeout: const Duration(milliseconds: 20)); + + var otherTasks = await Future.wait( + Iterable.generate(otherTaskCount) + .map((i) => pool.request())); + + try { + var finishedItems = 0; + + var watch = Stopwatch()..start(); + + await for (var item in pool.forEach( + Iterable.generate(itemCount, (i) { + expect(i, lessThanOrEqualTo(finishedItems + poolSize), + reason: 'the iterator should be called lazily'); + return i; + }), + delayedToString)) { + expect(int.parse(item), lessThan(itemCount)); + finishedItems++; + } + + expect(finishedItems, itemCount); + + final expectedElapsed = + delayedToStringDuration.inMicroseconds * 4; + + expect((watch.elapsed ~/ itemCount).inMicroseconds, + lessThan(expectedElapsed / (poolSize - otherTaskCount)), + reason: 'Average time per task should be ' + 'proportionate to the available pool resources.'); + } finally { + for (var task in otherTasks) { + task.release(); + } + } + }); + } + } + }, testOn: 'vm'); + + test('partial iteration', () async { + pool = Pool(5); + var stream = pool.forEach(Iterable.generate(100), delayedToString); + expect(await stream.take(10).toList(), hasLength(10)); + }); + + test('pool close during data with waiting to be done', () async { + pool = Pool(5); + + var stream = pool.forEach(Iterable.generate(100), delayedToString); + + var dataCount = 0; + var subscription = stream.listen((data) { + dataCount++; + pool.close(); + }); + + await subscription.asFuture(); + expect(dataCount, 100); + await subscription.cancel(); + }); + + test('pause and resume ', () async { + var generatedCount = 0; + var dataCount = 0; + final poolSize = 5; + + pool = Pool(poolSize); + + var stream = pool.forEach( + Iterable.generate(40, (i) { + expect(generatedCount, lessThanOrEqualTo(dataCount + 2 * poolSize), + reason: 'The iterator should not be called ' + 'much faster than the data is consumed.'); + generatedCount++; + return i; + }), + delayedToString); + + // ignore: cancel_subscriptions + late StreamSubscription subscription; + + subscription = stream.listen( + (data) { + dataCount++; + + if (int.parse(data) % 3 == 1) { + subscription.pause(Future(() async { + await Future.delayed(const Duration(milliseconds: 100)); + })); + } + }, + onError: registerException, + onDone: expectAsync0(() { + expect(dataCount, 40); + }), + ); + }); + + group('cancel', () { + final dataSize = 32; + for (var i = 1; i < 5; i++) { + test('with pool size $i', () async { + pool = Pool(i); + + var stream = + pool.forEach(Iterable.generate(dataSize), delayedToString); + + var cancelCompleter = Completer(); + + StreamSubscription subscription; + + var eventCount = 0; + subscription = stream.listen((data) { + eventCount++; + if (int.parse(data) == dataSize ~/ 2) { + cancelCompleter.complete(); + } + }, onError: registerException); + + await cancelCompleter.future; + + await subscription.cancel(); + + expect(eventCount, 1 + dataSize ~/ 2); + }); + } + }); + + group('errors', () { + Future errorInIterator({ + bool Function(int item, Object error, StackTrace stack)? onError, + }) async { + pool = Pool(20); + + var listFuture = pool + .forEach( + Iterable.generate(100, (i) { + if (i == 50) { + throw StateError('error while generating item in iterator'); + } + + return i; + }), + delayedToString, + onError: onError) + .toList(); + + await expectLater(() async => listFuture, throwsStateError); + } + + test('iteration, no onError', () async { + await errorInIterator(); + }); + test('iteration, with onError', () async { + await errorInIterator(onError: (i, e, s) => false); + }); + + test('error in action, no onError', () async { + pool = Pool(20); + + var listFuture = pool.forEach(Iterable.generate(100), (i) async { + await Future.delayed(const Duration(milliseconds: 10)); + if (i == 10) { + throw UnsupportedError('10 is not supported'); + } + return i.toString(); + }).toList(); + + await expectLater(() async => listFuture, throwsUnsupportedError); + }); + + test('error in action, no onError', () async { + pool = Pool(20); + + var list = await pool.forEach(Iterable.generate(100), + (int i) async { + await Future.delayed(const Duration(milliseconds: 10)); + if (i % 10 == 0) { + throw UnsupportedError('Multiples of 10 not supported'); + } + return i.toString(); + }, + onError: (item, error, stack) => + error is! UnsupportedError).toList(); + + expect(list, hasLength(90)); + }); + }); + }); + + test('throw error when pool limit <= 0', () { + expect(() => Pool(-1), throwsArgumentError); + expect(() => Pool(0), throwsArgumentError); + }); +} + +/// Returns a function that will cause the test to fail if it's called. +/// +/// This should only be called within a [FakeAsync.run] zone. +void Function() expectNoAsync() { + var stack = Trace.current(1); + return () => registerException( + TestFailure('Expected function not to be called.'), stack); +} + +/// A matcher for Futures that asserts that they don't complete. +/// +/// This should only be called within a [FakeAsync.run] zone. +Matcher get doesNotComplete => predicate((Future future) { + var stack = Trace.current(1); + future.then((_) => registerException( + TestFailure('Expected future not to complete.'), stack)); + return true; + }); diff --git a/pkgs/pub_semver/.gitignore b/pkgs/pub_semver/.gitignore new file mode 100644 index 000000000..49ce72d76 --- /dev/null +++ b/pkgs/pub_semver/.gitignore @@ -0,0 +1,3 @@ +.dart_tool/ +.packages +pubspec.lock diff --git a/pkgs/pub_semver/CHANGELOG.md b/pkgs/pub_semver/CHANGELOG.md new file mode 100644 index 000000000..a31fbb243 --- /dev/null +++ b/pkgs/pub_semver/CHANGELOG.md @@ -0,0 +1,177 @@ +## 2.1.5 + +- Require Dart `3.4.0`. +- Move to `dart-lang/tools` monorepo. + +## 2.1.4 + +- Added topics to `pubspec.yaml`. + +## 2.1.3 + +- Add type parameters to the signatures of the `Version.preRelease` and + `Version.build` fields (`List` ==> `List`). + [#74](https://github.com/dart-lang/pub_semver/pull/74). +- Require Dart 2.17. + +## 2.1.2 + +- Add markdown badges to the readme. + +## 2.1.1 + +- Fixed the version parsing pattern to only accept dots between version + components. + +## 2.1.0 + +- Added `Version.canonicalizedVersion` to help scrub leading zeros and highlight + that `Version.toString()` preserves leading zeros. +- Annotated `Version` with `@sealed` to discourage users from implementing the + interface. + +## 2.0.0 + +- Stable null safety release. +- `Version.primary` now throws `StateError` if the `versions` argument is empty. + +## 1.4.4 + +- Fix a bug of `VersionRange.union` where ranges bounded at infinity would get + combined wrongly. + +# 1.4.3 + +- Update Dart SDK constraint to `>=2.0.0 <3.0.0`. +- Update `package:collection` constraint to `^1.0.0`. + +## 1.4.2 + +* Set max SDK version to `<3.0.0`. + +## 1.4.1 + +* Fix a bug where there upper bound of a version range with a build identifier + could accidentally be rewritten. + +## 1.4.0 + +* Add a `Version.firstPreRelease` getter that returns the first possible + pre-release of a version. + +* Add a `Version.isFirstPreRelease` getter that returns whether a version is the + first possible pre-release. + +* `new VersionRange()` with an exclusive maximum now replaces the maximum with + its first pre-release version. This matches the existing semantics, where an + exclusive maximum would exclude pre-release versions of that maximum. + + Explicitly representing this by changing the maximum version ensures that all + operations behave correctly with respect to the special pre-release semantics. + In particular, it fixes bugs where, for example, + `(>=1.0.0 <2.0.0-dev).union(>=2.0.0-dev <2.0.0)` and + `(>=1.0.0 <3.0.0).difference(^1.0.0)` wouldn't include `2.0.0-dev`. + +* Add an `alwaysIncludeMaxPreRelease` parameter to `new VersionRange()`, which + disables the replacement described above and allows users to create ranges + that do include the pre-release versions of an exclusive max version. + +## 1.3.7 + +* Fix more bugs with `VersionRange.intersect()`, `VersionRange.difference()`, + and `VersionRange.union()` involving version ranges with pre-release maximums. + +## 1.3.6 + +* Fix a bug where constraints that only allowed pre-release versions would be + parsed as empty constraints. + +## 1.3.5 + +* Fix a bug where `VersionRange.intersect()` would return incorrect results for + pre-release versions with the same base version number as release versions. + +## 1.3.4 + +* Fix a bug where `VersionRange.allowsAll()`, `VersionRange.allowsAny()`, and + `VersionRange.difference()` would return incorrect results for pre-release + versions with the same base version number as release versions. + +## 1.3.3 + +* Fix a bug where `VersionRange.difference()` with a union constraint that + covered the entire range would crash. + +## 1.3.2 + +* Fix a checked-mode error in `VersionRange.difference()`. + +## 1.3.1 + +* Fix a new strong mode error. + +## 1.3.0 + +* Make the `VersionUnion` class public. This was previously used internally to + implement `new VersionConstraint.unionOf()` and `VersionConstraint.union()`. + Now it's public so you can use it too. + +* Added `VersionConstraint.difference()`. This returns a constraint matching all + versions matched by one constraint but not another. + +* Make `VersionRange` implement `Comparable`. Ranges are ordered + first by lower bound, then by upper bound. + +## 1.2.4 + +* Fix all remaining strong mode warnings. + +## 1.2.3 + +* Addressed three strong mode warnings. + +## 1.2.2 + +* Make the package analyze under strong mode and compile with the DDC (Dart Dev + Compiler). Fix two issues with a private subclass of `VersionConstraint` + having different types for overridden methods. + +## 1.2.1 + +* Allow version ranges like `>=1.2.3-dev.1 <1.2.3` to match pre-release versions + of `1.2.3`. Previously, these didn't match, since the pre-release versions had + the same major, minor, and patch numbers as the max; now an exception has been + added if they also have the same major, minor, and patch numbers as the min + *and* the min is also a pre-release version. + +## 1.2.0 + +* Add a `VersionConstraint.union()` method and a `new + VersionConstraint.unionOf()` constructor. These each return a constraint that + matches multiple existing constraints. + +* Add a `VersionConstraint.allowsAll()` method, which returns whether one + constraint is a superset of another. + +* Add a `VersionConstraint.allowsAny()` method, which returns whether one + constraint overlaps another. + +* `Version` now implements `VersionRange`. + +## 1.1.0 + +* Add support for the `^` operator for compatible versions according to pub's + notion of compatibility. `^1.2.3` is equivalent to `>=1.2.3 <2.0.0`; `^0.1.2` + is equivalent to `>=0.1.2 <0.2.0`. + +* Add `Version.nextBreaking`, which returns the next version that introduces + breaking changes after a given version. + +* Add `new VersionConstraint.compatibleWith()`, which returns a range covering + all versions compatible with a given version. + +* Add a custom `VersionRange.hashCode` to make it properly hashable. + +## 1.0.0 + +* Initial release. diff --git a/pkgs/pub_semver/LICENSE b/pkgs/pub_semver/LICENSE new file mode 100644 index 000000000..000cd7bec --- /dev/null +++ b/pkgs/pub_semver/LICENSE @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/pub_semver/README.md b/pkgs/pub_semver/README.md new file mode 100644 index 000000000..03c92a3c5 --- /dev/null +++ b/pkgs/pub_semver/README.md @@ -0,0 +1,107 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/pub_semver.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/pub_semver.yaml) +[![pub package](https://img.shields.io/pub/v/pub_semver.svg)](https://pub.dev/packages/pub_semver) +[![package publisher](https://img.shields.io/pub/publisher/pub_semver.svg)](https://pub.dev/packages/pub_semver/publisher) + +Handles version numbers and version constraints in the same way that [pub][] +does. + +## Semantics + +The semantics here very closely follow the +[Semantic Versioning spec version 2.0.0-rc.1][semver]. It differs from semver +in a few corner cases: + + * **Version ordering does take build suffixes into account.** This is unlike + semver 2.0.0 but like earlier versions of semver. Version `1.2.3+1` is + considered a lower number than `1.2.3+2`. + + Since a package may have published multiple versions that differ only by + build suffix, pub still has to pick one of them *somehow*. Semver leaves + that issue unresolved, so we just say that build numbers are sorted like + pre-release suffixes. + + * **Pre-release versions are excluded from most max ranges.** Let's say a + user is depending on "foo" with constraint `>=1.0.0 <2.0.0` and that "foo" + has published these versions: + + * `1.0.0` + * `1.1.0` + * `1.2.0` + * `2.0.0-alpha` + * `2.0.0-beta` + * `2.0.0` + * `2.1.0` + + Versions `2.0.0` and `2.1.0` are excluded by the constraint since neither + matches `<2.0.0`. However, since semver specifies that pre-release versions + are lower than the non-prerelease version (i.e. `2.0.0-beta < 2.0.0`, then + the `<2.0.0` constraint does technically allow those. + + But that's almost never what the user wants. If their package doesn't work + with foo `2.0.0`, it's certainly not likely to work with experimental, + unstable versions of `2.0.0`'s API, which is what pre-release versions + represent. + + To handle that, `<` version ranges don't allow pre-release versions of the + maximum unless the max is itself a pre-release, or the min is a pre-release + of the same version. In other words, a `<2.0.0` constraint will prohibit not + just `2.0.0` but any pre-release of `2.0.0`. However, `<2.0.0-beta` will + exclude `2.0.0-beta` but allow `2.0.0-alpha`. Likewise, `>2.0.0-alpha + <2.0.0` will exclude `2.0.0-alpha` but allow `2.0.0-beta`. + + * **Pre-release versions are avoided when possible.** The above case + handles pre-release versions at the top of the range, but what about in + the middle? What if "foo" has these versions: + + * `1.0.0` + * `1.2.0-alpha` + * `1.2.0` + * `1.3.0-experimental` + + When a number of versions are valid, pub chooses the best one where "best" + usually means "highest numbered". That follows the user's intuition that, + all else being equal, they want the latest and greatest. Here, that would + mean `1.3.0-experimental`. However, most users don't want to use unstable + versions of their dependencies. + + We want pre-releases to be explicitly opt-in so that package consumers + don't get unpleasant surprises and so that package maintainers are free to + put out pre-releases and get feedback without dragging all of their users + onto the bleeding edge. + + To accommodate that, when pub is choosing a version, it uses *priority* + order which is different from strict comparison ordering. Any stable + version is considered higher priority than any unstable version. The above + versions, in priority order, are: + + * `1.2.0-alpha` + * `1.3.0-experimental` + * `1.0.0` + * `1.2.0` + + This ensures that users only end up with an unstable version when there are + no alternatives. Usually this means they've picked a constraint that + specifically selects that unstable version -- they've deliberately opted + into it. + + * **There is a notion of compatibility between pre-1.0.0 versions.** Semver + deems all pre-1.0.0 versions to be incompatible. This means that the only + way to ensure compatibility when depending on a pre-1.0.0 package is to + pin the dependency to an exact version. Pinned version constraints prevent + automatic patch and pre-release updates. To avoid this situation, pub + defines the "next breaking" version as the version which increments the + major version if it's greater than zero, and the minor version otherwise, + resets subsequent digits to zero, and strips any pre-release or build + suffix. For example, here are some versions along with their next breaking + ones: + + `0.0.3` -> `0.1.0` + `0.7.2-alpha` -> `0.8.0` + `1.2.3` -> `2.0.0` + + To make use of this, pub defines a "^" operator which yields a version + constraint greater than or equal to a given version, but less than its next + breaking one. + +[pub]: https://pub.dev +[semver]: https://semver.org/spec/v2.0.0-rc.1.html diff --git a/pkgs/pub_semver/analysis_options.yaml b/pkgs/pub_semver/analysis_options.yaml new file mode 100644 index 000000000..76380a006 --- /dev/null +++ b/pkgs/pub_semver/analysis_options.yaml @@ -0,0 +1,31 @@ +# https://dart.dev/guides/language/analysis-options +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + - avoid_bool_literals_in_conditional_expressions + - avoid_classes_with_only_static_members + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_returning_this + - avoid_unused_constructor_parameters + - avoid_void_async + - cancel_subscriptions + - cascade_invocations + - join_return_with_assignment + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_runtimeType_toString + - prefer_const_declarations + - prefer_expression_function_bodies + - unnecessary_await_in_return + - use_if_null_to_convert_nulls_to_bools + - use_raw_strings + - use_string_buffers diff --git a/pkgs/pub_semver/example/example.dart b/pkgs/pub_semver/example/example.dart new file mode 100644 index 000000000..890343c93 --- /dev/null +++ b/pkgs/pub_semver/example/example.dart @@ -0,0 +1,17 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:pub_semver/pub_semver.dart'; + +void main() { + final range = VersionConstraint.parse('^2.0.0'); + + for (var version in [ + Version.parse('1.2.3-pre'), + Version.parse('2.0.0+123'), + Version.parse('3.0.0-dev'), + ]) { + print('$version ${version.isPreRelease} ${range.allows(version)}'); + } +} diff --git a/pkgs/pub_semver/lib/pub_semver.dart b/pkgs/pub_semver/lib/pub_semver.dart new file mode 100644 index 000000000..4b6487c9e --- /dev/null +++ b/pkgs/pub_semver/lib/pub_semver.dart @@ -0,0 +1,8 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'src/version.dart'; +export 'src/version_constraint.dart'; +export 'src/version_range.dart' hide CompatibleWithVersionRange; +export 'src/version_union.dart'; diff --git a/pkgs/pub_semver/lib/src/patterns.dart b/pkgs/pub_semver/lib/src/patterns.dart new file mode 100644 index 000000000..03119acf9 --- /dev/null +++ b/pkgs/pub_semver/lib/src/patterns.dart @@ -0,0 +1,19 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Regex that matches a version number at the beginning of a string. +final startVersion = RegExp(r'^' // Start at beginning. + r'(\d+)\.(\d+)\.(\d+)' // Version number. + r'(-([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?' // Pre-release. + r'(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?'); // Build. + +/// Like [startVersion] but matches the entire string. +final completeVersion = RegExp('${startVersion.pattern}\$'); + +/// Parses a comparison operator ("<", ">", "<=", or ">=") at the beginning of +/// a string. +final startComparison = RegExp(r'^[<>]=?'); + +/// The "compatible with" operator. +const compatibleWithChar = '^'; diff --git a/pkgs/pub_semver/lib/src/utils.dart b/pkgs/pub_semver/lib/src/utils.dart new file mode 100644 index 000000000..a9f714f02 --- /dev/null +++ b/pkgs/pub_semver/lib/src/utils.dart @@ -0,0 +1,58 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'version.dart'; +import 'version_range.dart'; + +/// Returns whether [range1] is immediately next to, but not overlapping, +/// [range2]. +bool areAdjacent(VersionRange range1, VersionRange range2) { + if (range1.max != range2.min) return false; + + return (range1.includeMax && !range2.includeMin) || + (!range1.includeMax && range2.includeMin); +} + +/// Returns whether [range1] allows lower versions than [range2]. +bool allowsLower(VersionRange range1, VersionRange range2) { + if (range1.min == null) return range2.min != null; + if (range2.min == null) return false; + + var comparison = range1.min!.compareTo(range2.min!); + if (comparison == -1) return true; + if (comparison == 1) return false; + return range1.includeMin && !range2.includeMin; +} + +/// Returns whether [range1] allows higher versions than [range2]. +bool allowsHigher(VersionRange range1, VersionRange range2) { + if (range1.max == null) return range2.max != null; + if (range2.max == null) return false; + + var comparison = range1.max!.compareTo(range2.max!); + if (comparison == 1) return true; + if (comparison == -1) return false; + return range1.includeMax && !range2.includeMax; +} + +/// Returns whether [range1] allows only versions lower than those allowed by +/// [range2]. +bool strictlyLower(VersionRange range1, VersionRange range2) { + if (range1.max == null || range2.min == null) return false; + + var comparison = range1.max!.compareTo(range2.min!); + if (comparison == -1) return true; + if (comparison == 1) return false; + return !range1.includeMax || !range2.includeMin; +} + +/// Returns whether [range1] allows only versions higher than those allowed by +/// [range2]. +bool strictlyHigher(VersionRange range1, VersionRange range2) => + strictlyLower(range2, range1); + +bool equalsWithoutPreRelease(Version version1, Version version2) => + version1.major == version2.major && + version1.minor == version2.minor && + version1.patch == version2.patch; diff --git a/pkgs/pub_semver/lib/src/version.dart b/pkgs/pub_semver/lib/src/version.dart new file mode 100644 index 000000000..90f3d535f --- /dev/null +++ b/pkgs/pub_semver/lib/src/version.dart @@ -0,0 +1,391 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart' show sealed; + +import 'patterns.dart'; +import 'version_constraint.dart'; +import 'version_range.dart'; + +/// The equality operator to use for comparing version components. +const _equality = IterableEquality(); + +/// A parsed semantic version number. +@sealed +class Version implements VersionConstraint, VersionRange { + /// No released version: i.e. "0.0.0". + static Version get none => Version(0, 0, 0); + + /// Compares [a] and [b] to see which takes priority over the other. + /// + /// Returns `1` if [a] takes priority over [b] and `-1` if vice versa. If + /// [a] and [b] are equivalent, returns `0`. + /// + /// Unlike [compareTo], which *orders* versions, this determines which + /// version a user is likely to prefer. In particular, it prioritizes + /// pre-release versions lower than stable versions, regardless of their + /// version numbers. Pub uses this when determining which version to prefer + /// when a number of versions are allowed. In that case, it will always + /// choose a stable version when possible. + /// + /// When used to sort a list, orders in ascending priority so that the + /// highest priority version is *last* in the result. + static int prioritize(Version a, Version b) { + // Sort all prerelease versions after all normal versions. This way + // the solver will prefer stable packages over unstable ones. + if (a.isPreRelease && !b.isPreRelease) return -1; + if (!a.isPreRelease && b.isPreRelease) return 1; + + return a.compareTo(b); + } + + /// Like [prioritize], but lower version numbers are considered greater than + /// higher version numbers. + /// + /// This still considers prerelease versions to be lower than non-prerelease + /// versions. Pub uses this when downgrading -- it chooses the lowest version + /// but still excludes pre-release versions when possible. + static int antiprioritize(Version a, Version b) { + if (a.isPreRelease && !b.isPreRelease) return -1; + if (!a.isPreRelease && b.isPreRelease) return 1; + + return b.compareTo(a); + } + + /// The major version number: "1" in "1.2.3". + final int major; + + /// The minor version number: "2" in "1.2.3". + final int minor; + + /// The patch version number: "3" in "1.2.3". + final int patch; + + /// The pre-release identifier: "foo" in "1.2.3-foo". + /// + /// This is split into a list of components, each of which may be either a + /// string or a non-negative integer. It may also be empty, indicating that + /// this version has no pre-release identifier. + final List preRelease; + + /// The build identifier: "foo" in "1.2.3+foo". + /// + /// This is split into a list of components, each of which may be either a + /// string or a non-negative integer. It may also be empty, indicating that + /// this version has no build identifier. + final List build; + + /// The original string representation of the version number. + /// + /// This preserves textual artifacts like leading zeros that may be left out + /// of the parsed version. + final String _text; + + @override + Version get min => this; + @override + Version get max => this; + @override + bool get includeMin => true; + @override + bool get includeMax => true; + + Version._(this.major, this.minor, this.patch, String? preRelease, + String? build, this._text) + : preRelease = preRelease == null ? [] : _splitParts(preRelease), + build = build == null ? [] : _splitParts(build) { + if (major < 0) throw ArgumentError('Major version must be non-negative.'); + if (minor < 0) throw ArgumentError('Minor version must be non-negative.'); + if (patch < 0) throw ArgumentError('Patch version must be non-negative.'); + } + + /// Creates a new [Version] object. + factory Version(int major, int minor, int patch, + {String? pre, String? build}) { + var text = '$major.$minor.$patch'; + if (pre != null) text += '-$pre'; + if (build != null) text += '+$build'; + + return Version._(major, minor, patch, pre, build, text); + } + + /// Creates a new [Version] by parsing [text]. + factory Version.parse(String text) { + final match = completeVersion.firstMatch(text); + if (match == null) { + throw FormatException('Could not parse "$text".'); + } + + try { + var major = int.parse(match[1]!); + var minor = int.parse(match[2]!); + var patch = int.parse(match[3]!); + + var preRelease = match[5]; + var build = match[8]; + + return Version._(major, minor, patch, preRelease, build, text); + } on FormatException { + throw FormatException('Could not parse "$text".'); + } + } + + /// Returns the primary version out of [versions]. + /// + /// This is the highest-numbered stable (non-prerelease) version. If there + /// are no stable versions, it's just the highest-numbered version. + /// + /// If [versions] is empty, throws a [StateError]. + static Version primary(List versions) { + var primary = versions.first; + for (var version in versions.skip(1)) { + if ((!version.isPreRelease && primary.isPreRelease) || + (version.isPreRelease == primary.isPreRelease && version > primary)) { + primary = version; + } + } + return primary; + } + + /// Splits a string of dot-delimited identifiers into their component parts. + /// + /// Identifiers that are numeric are converted to numbers. + static List _splitParts(String text) => text + .split('.') + .map((part) => + // Return an integer part if possible, otherwise return the string + // as-is + int.tryParse(part) ?? part) + .toList(); + + @override + bool operator ==(Object other) => + other is Version && + major == other.major && + minor == other.minor && + patch == other.patch && + _equality.equals(preRelease, other.preRelease) && + _equality.equals(build, other.build); + + @override + int get hashCode => + major ^ + minor ^ + patch ^ + _equality.hash(preRelease) ^ + _equality.hash(build); + + bool operator <(Version other) => compareTo(other) < 0; + bool operator >(Version other) => compareTo(other) > 0; + bool operator <=(Version other) => compareTo(other) <= 0; + bool operator >=(Version other) => compareTo(other) >= 0; + + @override + bool get isAny => false; + @override + bool get isEmpty => false; + + /// Whether or not this is a pre-release version. + bool get isPreRelease => preRelease.isNotEmpty; + + /// Gets the next major version number that follows this one. + /// + /// If this version is a pre-release of a major version release (i.e. the + /// minor and patch versions are zero), then it just strips the pre-release + /// suffix. Otherwise, it increments the major version and resets the minor + /// and patch. + Version get nextMajor { + if (isPreRelease && minor == 0 && patch == 0) { + return Version(major, minor, patch); + } + + return _incrementMajor(); + } + + /// Gets the next minor version number that follows this one. + /// + /// If this version is a pre-release of a minor version release (i.e. the + /// patch version is zero), then it just strips the pre-release suffix. + /// Otherwise, it increments the minor version and resets the patch. + Version get nextMinor { + if (isPreRelease && patch == 0) { + return Version(major, minor, patch); + } + + return _incrementMinor(); + } + + /// Gets the next patch version number that follows this one. + /// + /// If this version is a pre-release, then it just strips the pre-release + /// suffix. Otherwise, it increments the patch version. + Version get nextPatch { + if (isPreRelease) { + return Version(major, minor, patch); + } + + return _incrementPatch(); + } + + /// Gets the next breaking version number that follows this one. + /// + /// Increments [major] if it's greater than zero, otherwise [minor], resets + /// subsequent digits to zero, and strips any [preRelease] or [build] + /// suffix. + Version get nextBreaking { + if (major == 0) { + return _incrementMinor(); + } + + return _incrementMajor(); + } + + /// Returns the first possible pre-release of this version. + Version get firstPreRelease => Version(major, minor, patch, pre: '0'); + + /// Returns whether this is the first possible pre-release of its version. + bool get isFirstPreRelease => preRelease.length == 1 && preRelease.first == 0; + + Version _incrementMajor() => Version(major + 1, 0, 0); + Version _incrementMinor() => Version(major, minor + 1, 0); + Version _incrementPatch() => Version(major, minor, patch + 1); + + /// Tests if [other] matches this version exactly. + @override + bool allows(Version other) => this == other; + + @override + bool allowsAll(VersionConstraint other) => other.isEmpty || other == this; + + @override + bool allowsAny(VersionConstraint other) => other.allows(this); + + @override + VersionConstraint intersect(VersionConstraint other) => + other.allows(this) ? this : VersionConstraint.empty; + + @override + VersionConstraint union(VersionConstraint other) { + if (other.allows(this)) return other; + + if (other is VersionRange) { + if (other.min == this) { + return VersionRange( + min: other.min, + max: other.max, + includeMin: true, + includeMax: other.includeMax, + alwaysIncludeMaxPreRelease: true); + } + + if (other.max == this) { + return VersionRange( + min: other.min, + max: other.max, + includeMin: other.includeMin, + includeMax: true, + alwaysIncludeMaxPreRelease: true); + } + } + + return VersionConstraint.unionOf([this, other]); + } + + @override + VersionConstraint difference(VersionConstraint other) => + other.allows(this) ? VersionConstraint.empty : this; + + @override + int compareTo(VersionRange other) { + if (other is Version) { + if (major != other.major) return major.compareTo(other.major); + if (minor != other.minor) return minor.compareTo(other.minor); + if (patch != other.patch) return patch.compareTo(other.patch); + + // Pre-releases always come before no pre-release string. + if (!isPreRelease && other.isPreRelease) return 1; + if (!other.isPreRelease && isPreRelease) return -1; + + var comparison = _compareLists(preRelease, other.preRelease); + if (comparison != 0) return comparison; + + // Builds always come after no build string. + if (build.isEmpty && other.build.isNotEmpty) return -1; + if (other.build.isEmpty && build.isNotEmpty) return 1; + return _compareLists(build, other.build); + } else { + return -other.compareTo(this); + } + } + + /// Get non-canonical string representation of this [Version]. + /// + /// If created with [Version.parse], the string from which the version was + /// parsed is returned. Unlike the [canonicalizedVersion] this preserves + /// artifacts such as leading zeros. + @override + String toString() => _text; + + /// Get a canonicalized string representation of this [Version]. + /// + /// Unlike [Version.toString()] this always returns a canonical string + /// representation of this [Version]. + /// + /// **Example** + /// ```dart + /// final v = Version.parse('01.02.03-01.dev+pre.02'); + /// + /// assert(v.toString() == '01.02.03-01.dev+pre.02'); + /// assert(v.canonicalizedVersion == '1.2.3-1.dev+pre.2'); + /// assert(Version.parse(v.canonicalizedVersion) == v); + /// ``` + String get canonicalizedVersion => Version( + major, + minor, + patch, + pre: preRelease.isNotEmpty ? preRelease.join('.') : null, + build: build.isNotEmpty ? build.join('.') : null, + ).toString(); + + /// Compares a dot-separated component of two versions. + /// + /// This is used for the pre-release and build version parts. This follows + /// Rule 12 of the Semantic Versioning spec (v2.0.0-rc.1). + int _compareLists(List a, List b) { + for (var i = 0; i < math.max(a.length, b.length); i++) { + var aPart = (i < a.length) ? a[i] : null; + var bPart = (i < b.length) ? b[i] : null; + + if (aPart == bPart) continue; + + // Missing parts come before present ones. + if (aPart == null) return -1; + if (bPart == null) return 1; + + if (aPart is num) { + if (bPart is num) { + // Compare two numbers. + return aPart.compareTo(bPart); + } else { + // Numbers come before strings. + return -1; + } + } else { + if (bPart is num) { + // Strings come after numbers. + return 1; + } else { + // Compare two strings. + return (aPart as String).compareTo(bPart as String); + } + } + } + + // The lists are entirely equal. + return 0; + } +} diff --git a/pkgs/pub_semver/lib/src/version_constraint.dart b/pkgs/pub_semver/lib/src/version_constraint.dart new file mode 100644 index 000000000..948118ef3 --- /dev/null +++ b/pkgs/pub_semver/lib/src/version_constraint.dart @@ -0,0 +1,287 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'patterns.dart'; +import 'utils.dart'; +import 'version.dart'; +import 'version_range.dart'; +import 'version_union.dart'; + +/// A [VersionConstraint] is a predicate that can determine whether a given +/// version is valid or not. +/// +/// For example, a ">= 2.0.0" constraint allows any version that is "2.0.0" or +/// greater. Version objects themselves implement this to match a specific +/// version. +abstract class VersionConstraint { + /// A [VersionConstraint] that allows all versions. + static VersionConstraint any = VersionRange(); + + /// A [VersionConstraint] that allows no versions -- the empty set. + static VersionConstraint empty = const _EmptyVersion(); + + /// Parses a version constraint. + /// + /// This string is one of: + /// + /// * "any". [any] version. + /// * "^" followed by a version string. Versions compatible with + /// ([VersionConstraint.compatibleWith]) the version. + /// * a series of version parts. Each part can be one of: + /// * A version string like `1.2.3`. In other words, anything that can be + /// parsed by [Version.parse()]. + /// * A comparison operator (`<`, `>`, `<=`, or `>=`) followed by a + /// version string. + /// + /// Whitespace is ignored. + /// + /// Examples: + /// + /// any + /// ^0.7.2 + /// ^1.0.0-alpha + /// 1.2.3-alpha + /// <=5.1.4 + /// >2.0.4 <= 2.4.6 + factory VersionConstraint.parse(String text) { + var originalText = text; + + void skipWhitespace() { + text = text.trim(); + } + + skipWhitespace(); + + // Handle the "any" constraint. + if (text == 'any') return any; + + // Try to parse and consume a version number. + Version? matchVersion() { + var version = startVersion.firstMatch(text); + if (version == null) return null; + + text = text.substring(version.end); + return Version.parse(version[0]!); + } + + // Try to parse and consume a comparison operator followed by a version. + VersionRange? matchComparison() { + var comparison = startComparison.firstMatch(text); + if (comparison == null) return null; + + var op = comparison[0]!; + text = text.substring(comparison.end); + skipWhitespace(); + + var version = matchVersion(); + if (version == null) { + throw FormatException('Expected version number after "$op" in ' + '"$originalText", got "$text".'); + } + + return switch (op) { + '<=' => VersionRange(max: version, includeMax: true), + '<' => VersionRange(max: version, alwaysIncludeMaxPreRelease: true), + '>=' => VersionRange(min: version, includeMin: true), + '>' => VersionRange(min: version), + _ => throw UnsupportedError(op), + }; + } + + // Try to parse the "^" operator followed by a version. + VersionConstraint? matchCompatibleWith() { + if (!text.startsWith(compatibleWithChar)) return null; + + text = text.substring(compatibleWithChar.length); + skipWhitespace(); + + var version = matchVersion(); + if (version == null) { + throw FormatException('Expected version number after ' + '"$compatibleWithChar" in "$originalText", got "$text".'); + } + + if (text.isNotEmpty) { + throw FormatException('Cannot include other constraints with ' + '"$compatibleWithChar" constraint in "$originalText".'); + } + + return VersionConstraint.compatibleWith(version); + } + + var compatibleWith = matchCompatibleWith(); + if (compatibleWith != null) return compatibleWith; + + Version? min; + var includeMin = false; + Version? max; + var includeMax = false; + + for (;;) { + skipWhitespace(); + + if (text.isEmpty) break; + + var newRange = matchVersion() ?? matchComparison(); + if (newRange == null) { + throw FormatException('Could not parse version "$originalText". ' + 'Unknown text at "$text".'); + } + + if (newRange.min != null) { + if (min == null || newRange.min! > min) { + min = newRange.min; + includeMin = newRange.includeMin; + } else if (newRange.min == min && !newRange.includeMin) { + includeMin = false; + } + } + + if (newRange.max != null) { + if (max == null || newRange.max! < max) { + max = newRange.max; + includeMax = newRange.includeMax; + } else if (newRange.max == max && !newRange.includeMax) { + includeMax = false; + } + } + } + + if (min == null && max == null) { + throw const FormatException('Cannot parse an empty string.'); + } + + if (min != null && max != null) { + if (min > max) return VersionConstraint.empty; + if (min == max) { + if (includeMin && includeMax) return min; + return VersionConstraint.empty; + } + } + + return VersionRange( + min: min, includeMin: includeMin, max: max, includeMax: includeMax); + } + + /// Creates a version constraint which allows all versions that are + /// backward compatible with [version]. + /// + /// Versions are considered backward compatible with [version] if they + /// are greater than or equal to [version], but less than the next breaking + /// version ([Version.nextBreaking]) of [version]. + factory VersionConstraint.compatibleWith(Version version) => + CompatibleWithVersionRange(version); + + /// Creates a new version constraint that is the intersection of + /// [constraints]. + /// + /// It only allows versions that all of those constraints allow. If + /// constraints is empty, then it returns a VersionConstraint that allows + /// all versions. + factory VersionConstraint.intersection( + Iterable constraints) { + var constraint = VersionRange(); + for (var other in constraints) { + constraint = constraint.intersect(other) as VersionRange; + } + return constraint; + } + + /// Creates a new version constraint that is the union of [constraints]. + /// + /// It allows any versions that any of those constraints allows. If + /// [constraints] is empty, this returns a constraint that allows no versions. + factory VersionConstraint.unionOf(Iterable constraints) { + var flattened = constraints.expand((constraint) { + if (constraint.isEmpty) return []; + if (constraint is VersionUnion) return constraint.ranges; + if (constraint is VersionRange) return [constraint]; + throw ArgumentError('Unknown VersionConstraint type $constraint.'); + }).toList(); + + if (flattened.isEmpty) return VersionConstraint.empty; + + if (flattened.any((constraint) => constraint.isAny)) { + return VersionConstraint.any; + } + + flattened.sort(); + + var merged = []; + for (var constraint in flattened) { + // Merge this constraint with the previous one, but only if they touch. + if (merged.isEmpty || + (!merged.last.allowsAny(constraint) && + !areAdjacent(merged.last, constraint))) { + merged.add(constraint); + } else { + merged[merged.length - 1] = + merged.last.union(constraint) as VersionRange; + } + } + + if (merged.length == 1) return merged.single; + return VersionUnion.fromRanges(merged); + } + + /// Returns `true` if this constraint allows no versions. + bool get isEmpty; + + /// Returns `true` if this constraint allows all versions. + bool get isAny; + + /// Returns `true` if this constraint allows [version]. + bool allows(Version version); + + /// Returns `true` if this constraint allows all the versions that [other] + /// allows. + bool allowsAll(VersionConstraint other); + + /// Returns `true` if this constraint allows any of the versions that [other] + /// allows. + bool allowsAny(VersionConstraint other); + + /// Returns a [VersionConstraint] that only allows [Version]s allowed by both + /// this and [other]. + VersionConstraint intersect(VersionConstraint other); + + /// Returns a [VersionConstraint] that allows [Version]s allowed by either + /// this or [other]. + VersionConstraint union(VersionConstraint other); + + /// Returns a [VersionConstraint] that allows [Version]s allowed by this but + /// not [other]. + VersionConstraint difference(VersionConstraint other); +} + +class _EmptyVersion implements VersionConstraint { + const _EmptyVersion(); + + @override + bool get isEmpty => true; + + @override + bool get isAny => false; + + @override + bool allows(Version other) => false; + + @override + bool allowsAll(VersionConstraint other) => other.isEmpty; + + @override + bool allowsAny(VersionConstraint other) => false; + + @override + VersionConstraint intersect(VersionConstraint other) => this; + + @override + VersionConstraint union(VersionConstraint other) => other; + + @override + VersionConstraint difference(VersionConstraint other) => this; + + @override + String toString() => ''; +} diff --git a/pkgs/pub_semver/lib/src/version_range.dart b/pkgs/pub_semver/lib/src/version_range.dart new file mode 100644 index 000000000..6f2ed54b0 --- /dev/null +++ b/pkgs/pub_semver/lib/src/version_range.dart @@ -0,0 +1,476 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'utils.dart'; +import 'version.dart'; +import 'version_constraint.dart'; +import 'version_union.dart'; + +/// Constrains versions to a fall within a given range. +/// +/// If there is a minimum, then this only allows versions that are at that +/// minimum or greater. If there is a maximum, then only versions less than +/// that are allowed. In other words, this allows `>= min, < max`. +/// +/// Version ranges are ordered first by their lower bounds, then by their upper +/// bounds. For example, `>=1.0.0 <2.0.0` is before `>=1.5.0 <2.0.0` is before +/// `>=1.5.0 <3.0.0`. +class VersionRange implements Comparable, VersionConstraint { + /// The minimum end of the range. + /// + /// If [includeMin] is `true`, this will be the minimum allowed version. + /// Otherwise, it will be the highest version below the range that is not + /// allowed. + /// + /// This may be `null` in which case the range has no minimum end and allows + /// any version less than the maximum. + final Version? min; + + /// The maximum end of the range. + /// + /// If [includeMax] is `true`, this will be the maximum allowed version. + /// Otherwise, it will be the lowest version above the range that is not + /// allowed. + /// + /// This may be `null` in which case the range has no maximum end and allows + /// any version greater than the minimum. + final Version? max; + + /// If `true` then [min] is allowed by the range. + final bool includeMin; + + /// If `true`, then [max] is allowed by the range. + final bool includeMax; + + /// Creates a new version range from [min] to [max], either inclusive or + /// exclusive. + /// + /// If it is an error if [min] is greater than [max]. + /// + /// Either [max] or [min] may be omitted to not clamp the range at that end. + /// If both are omitted, the range allows all versions. + /// + /// If [includeMin] is `true`, then the minimum end of the range is inclusive. + /// Likewise, passing [includeMax] as `true` makes the upper end inclusive. + /// + /// If [alwaysIncludeMaxPreRelease] is `true`, this will always include + /// pre-release versions of an exclusive [max]. Otherwise, it will use the + /// default behavior for pre-release versions of [max]. + factory VersionRange( + {Version? min, + Version? max, + bool includeMin = false, + bool includeMax = false, + bool alwaysIncludeMaxPreRelease = false}) { + if (min != null && max != null && min > max) { + throw ArgumentError( + 'Minimum version ("$min") must be less than maximum ("$max").'); + } + + if (!alwaysIncludeMaxPreRelease && + !includeMax && + max != null && + !max.isPreRelease && + max.build.isEmpty && + (min == null || + !min.isPreRelease || + !equalsWithoutPreRelease(min, max))) { + max = max.firstPreRelease; + } + + return VersionRange._(min, max, includeMin, includeMax); + } + + VersionRange._(this.min, this.max, this.includeMin, this.includeMax); + + @override + bool operator ==(Object other) { + if (other is! VersionRange) return false; + + return min == other.min && + max == other.max && + includeMin == other.includeMin && + includeMax == other.includeMax; + } + + @override + int get hashCode => + min.hashCode ^ + (max.hashCode * 3) ^ + (includeMin.hashCode * 5) ^ + (includeMax.hashCode * 7); + + @override + bool get isEmpty => false; + + @override + bool get isAny => min == null && max == null; + + /// Tests if [other] falls within this version range. + @override + bool allows(Version other) { + if (min != null) { + if (other < min!) return false; + if (!includeMin && other == min) return false; + } + + if (max != null) { + if (other > max!) return false; + if (!includeMax && other == max) return false; + } + + return true; + } + + @override + bool allowsAll(VersionConstraint other) { + if (other.isEmpty) return true; + if (other is Version) return allows(other); + + if (other is VersionUnion) { + return other.ranges.every(allowsAll); + } + + if (other is VersionRange) { + return !allowsLower(other, this) && !allowsHigher(other, this); + } + + throw ArgumentError('Unknown VersionConstraint type $other.'); + } + + @override + bool allowsAny(VersionConstraint other) { + if (other.isEmpty) return false; + if (other is Version) return allows(other); + + if (other is VersionUnion) { + return other.ranges.any(allowsAny); + } + + if (other is VersionRange) { + return !strictlyLower(other, this) && !strictlyHigher(other, this); + } + + throw ArgumentError('Unknown VersionConstraint type $other.'); + } + + @override + VersionConstraint intersect(VersionConstraint other) { + if (other.isEmpty) return other; + if (other is VersionUnion) return other.intersect(this); + + // A range and a Version just yields the version if it's in the range. + if (other is Version) { + return allows(other) ? other : VersionConstraint.empty; + } + + if (other is VersionRange) { + // Intersect the two ranges. + Version? intersectMin; + bool intersectIncludeMin; + if (allowsLower(this, other)) { + if (strictlyLower(this, other)) return VersionConstraint.empty; + intersectMin = other.min; + intersectIncludeMin = other.includeMin; + } else { + if (strictlyLower(other, this)) return VersionConstraint.empty; + intersectMin = min; + intersectIncludeMin = includeMin; + } + + Version? intersectMax; + bool intersectIncludeMax; + if (allowsHigher(this, other)) { + intersectMax = other.max; + intersectIncludeMax = other.includeMax; + } else { + intersectMax = max; + intersectIncludeMax = includeMax; + } + + if (intersectMin == null && intersectMax == null) { + // Open range. + return VersionRange(); + } + + // If the range is just a single version. + if (intersectMin == intersectMax) { + // Because we already verified that the lower range isn't strictly + // lower, there must be some overlap. + assert(intersectIncludeMin && intersectIncludeMax); + return intersectMin!; + } + + // If we got here, there is an actual range. + return VersionRange( + min: intersectMin, + max: intersectMax, + includeMin: intersectIncludeMin, + includeMax: intersectIncludeMax, + alwaysIncludeMaxPreRelease: true); + } + + throw ArgumentError('Unknown VersionConstraint type $other.'); + } + + @override + VersionConstraint union(VersionConstraint other) { + if (other is Version) { + if (allows(other)) return this; + + if (other == min) { + return VersionRange( + min: min, + max: max, + includeMin: true, + includeMax: includeMax, + alwaysIncludeMaxPreRelease: true); + } + + if (other == max) { + return VersionRange( + min: min, + max: max, + includeMin: includeMin, + includeMax: true, + alwaysIncludeMaxPreRelease: true); + } + + return VersionConstraint.unionOf([this, other]); + } + + if (other is VersionRange) { + // If the two ranges don't overlap, we won't be able to create a single + // VersionRange for both of them. + var edgesTouch = (max != null && + max == other.min && + (includeMax || other.includeMin)) || + (min != null && min == other.max && (includeMin || other.includeMax)); + if (!edgesTouch && !allowsAny(other)) { + return VersionConstraint.unionOf([this, other]); + } + + Version? unionMin; + bool unionIncludeMin; + if (allowsLower(this, other)) { + unionMin = min; + unionIncludeMin = includeMin; + } else { + unionMin = other.min; + unionIncludeMin = other.includeMin; + } + + Version? unionMax; + bool unionIncludeMax; + if (allowsHigher(this, other)) { + unionMax = max; + unionIncludeMax = includeMax; + } else { + unionMax = other.max; + unionIncludeMax = other.includeMax; + } + + return VersionRange( + min: unionMin, + max: unionMax, + includeMin: unionIncludeMin, + includeMax: unionIncludeMax, + alwaysIncludeMaxPreRelease: true); + } + + return VersionConstraint.unionOf([this, other]); + } + + @override + VersionConstraint difference(VersionConstraint other) { + if (other.isEmpty) return this; + + if (other is Version) { + if (!allows(other)) return this; + + if (other == min) { + if (!includeMin) return this; + return VersionRange( + min: min, + max: max, + includeMax: includeMax, + alwaysIncludeMaxPreRelease: true); + } + + if (other == max) { + if (!includeMax) return this; + return VersionRange( + min: min, + max: max, + includeMin: includeMin, + alwaysIncludeMaxPreRelease: true); + } + + return VersionUnion.fromRanges([ + VersionRange( + min: min, + max: other, + includeMin: includeMin, + alwaysIncludeMaxPreRelease: true), + VersionRange( + min: other, + max: max, + includeMax: includeMax, + alwaysIncludeMaxPreRelease: true) + ]); + } else if (other is VersionRange) { + if (!allowsAny(other)) return this; + + VersionRange? before; + if (!allowsLower(this, other)) { + before = null; + } else if (min == other.min) { + assert(includeMin && !other.includeMin); + assert(min != null); + before = min; + } else { + before = VersionRange( + min: min, + max: other.min, + includeMin: includeMin, + includeMax: !other.includeMin, + alwaysIncludeMaxPreRelease: true); + } + + VersionRange? after; + if (!allowsHigher(this, other)) { + after = null; + } else if (max == other.max) { + assert(includeMax && !other.includeMax); + assert(max != null); + after = max; + } else { + after = VersionRange( + min: other.max, + max: max, + includeMin: !other.includeMax, + includeMax: includeMax, + alwaysIncludeMaxPreRelease: true); + } + + if (before == null && after == null) return VersionConstraint.empty; + if (before == null) return after!; + if (after == null) return before; + return VersionUnion.fromRanges([before, after]); + } else if (other is VersionUnion) { + var ranges = []; + var current = this; + + for (var range in other.ranges) { + // Skip any ranges that are strictly lower than [current]. + if (strictlyLower(range, current)) continue; + + // If we reach a range strictly higher than [current], no more ranges + // will be relevant so we can bail early. + if (strictlyHigher(range, current)) break; + + var difference = current.difference(range); + if (difference.isEmpty) { + return VersionConstraint.empty; + } else if (difference is VersionUnion) { + // If [range] split [current] in half, we only need to continue + // checking future ranges against the latter half. + assert(difference.ranges.length == 2); + ranges.add(difference.ranges.first); + current = difference.ranges.last; + } else { + current = difference as VersionRange; + } + } + + if (ranges.isEmpty) return current; + return VersionUnion.fromRanges(ranges..add(current)); + } + + throw ArgumentError('Unknown VersionConstraint type $other.'); + } + + @override + int compareTo(VersionRange other) { + if (min == null) { + if (other.min == null) return _compareMax(other); + return -1; + } else if (other.min == null) { + return 1; + } + + var result = min!.compareTo(other.min!); + if (result != 0) return result; + if (includeMin != other.includeMin) return includeMin ? -1 : 1; + + return _compareMax(other); + } + + /// Compares the maximum values of `this` and [other]. + int _compareMax(VersionRange other) { + if (max == null) { + if (other.max == null) return 0; + return 1; + } else if (other.max == null) { + return -1; + } + + var result = max!.compareTo(other.max!); + if (result != 0) return result; + if (includeMax != other.includeMax) return includeMax ? 1 : -1; + return 0; + } + + @override + String toString() { + var buffer = StringBuffer(); + + final min = this.min; + if (min != null) { + buffer + ..write(includeMin ? '>=' : '>') + ..write(min); + } + + final max = this.max; + + if (max != null) { + if (min != null) buffer.write(' '); + if (includeMax) { + buffer + ..write('<=') + ..write(max); + } else { + buffer.write('<'); + if (max.isFirstPreRelease) { + // Since `"<$max"` would parse the same as `"<$max-0"`, we just emit + // `<$max` to avoid confusing "-0" suffixes. + buffer.write('${max.major}.${max.minor}.${max.patch}'); + } else { + buffer.write(max); + + // If `">=$min <$max"` would parse as `">=$min <$max-0"`, add `-*` to + // indicate that actually does allow pre-release versions. + var minIsPreReleaseOfMax = min != null && + min.isPreRelease && + equalsWithoutPreRelease(min, max); + if (!max.isPreRelease && max.build.isEmpty && !minIsPreReleaseOfMax) { + buffer.write('-∞'); + } + } + } + } + + if (min == null && max == null) buffer.write('any'); + return buffer.toString(); + } +} + +class CompatibleWithVersionRange extends VersionRange { + CompatibleWithVersionRange(Version version) + : super._(version, version.nextBreaking.firstPreRelease, true, false); + + @override + String toString() => '^$min'; +} diff --git a/pkgs/pub_semver/lib/src/version_union.dart b/pkgs/pub_semver/lib/src/version_union.dart new file mode 100644 index 000000000..844d3b8ef --- /dev/null +++ b/pkgs/pub_semver/lib/src/version_union.dart @@ -0,0 +1,224 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:collection/collection.dart'; + +import 'utils.dart'; +import 'version.dart'; +import 'version_constraint.dart'; +import 'version_range.dart'; + +/// A version constraint representing a union of multiple disjoint version +/// ranges. +/// +/// An instance of this will only be created if the version can't be represented +/// as a non-compound value. +class VersionUnion implements VersionConstraint { + /// The constraints that compose this union. + /// + /// This list has two invariants: + /// + /// * Its contents are sorted using the standard ordering of [VersionRange]s. + /// * Its contents are disjoint and non-adjacent. In other words, for any two + /// constraints next to each other in the list, there's some version between + /// those constraints that they don't match. + final List ranges; + + @override + bool get isEmpty => false; + + @override + bool get isAny => false; + + /// Creates a union from a list of ranges with no pre-processing. + /// + /// It's up to the caller to ensure that the invariants described in [ranges] + /// are maintained. They are not verified by this constructor. To + /// automatically ensure that they're maintained, use + /// [VersionConstraint.unionOf] instead. + VersionUnion.fromRanges(this.ranges); + + @override + bool allows(Version version) => + ranges.any((constraint) => constraint.allows(version)); + + @override + bool allowsAll(VersionConstraint other) { + var ourRanges = ranges.iterator; + var theirRanges = _rangesFor(other).iterator; + + // Because both lists of ranges are ordered by minimum version, we can + // safely move through them linearly here. + var ourRangesMoved = ourRanges.moveNext(); + var theirRangesMoved = theirRanges.moveNext(); + while (ourRangesMoved && theirRangesMoved) { + if (ourRanges.current.allowsAll(theirRanges.current)) { + theirRangesMoved = theirRanges.moveNext(); + } else { + ourRangesMoved = ourRanges.moveNext(); + } + } + + // If our ranges have allowed all of their ranges, we'll have consumed all + // of them. + return !theirRangesMoved; + } + + @override + bool allowsAny(VersionConstraint other) { + var ourRanges = ranges.iterator; + var theirRanges = _rangesFor(other).iterator; + + // Because both lists of ranges are ordered by minimum version, we can + // safely move through them linearly here. + var ourRangesMoved = ourRanges.moveNext(); + var theirRangesMoved = theirRanges.moveNext(); + while (ourRangesMoved && theirRangesMoved) { + if (ourRanges.current.allowsAny(theirRanges.current)) { + return true; + } + + // Move the constraint with the lower max value forward. This ensures that + // we keep both lists in sync as much as possible. + if (allowsHigher(theirRanges.current, ourRanges.current)) { + ourRangesMoved = ourRanges.moveNext(); + } else { + theirRangesMoved = theirRanges.moveNext(); + } + } + + return false; + } + + @override + VersionConstraint intersect(VersionConstraint other) { + var ourRanges = ranges.iterator; + var theirRanges = _rangesFor(other).iterator; + + // Because both lists of ranges are ordered by minimum version, we can + // safely move through them linearly here. + var newRanges = []; + var ourRangesMoved = ourRanges.moveNext(); + var theirRangesMoved = theirRanges.moveNext(); + while (ourRangesMoved && theirRangesMoved) { + var intersection = ourRanges.current.intersect(theirRanges.current); + + if (!intersection.isEmpty) newRanges.add(intersection as VersionRange); + + // Move the constraint with the lower max value forward. This ensures that + // we keep both lists in sync as much as possible, and that large ranges + // have a chance to match multiple small ranges that they contain. + if (allowsHigher(theirRanges.current, ourRanges.current)) { + ourRangesMoved = ourRanges.moveNext(); + } else { + theirRangesMoved = theirRanges.moveNext(); + } + } + + if (newRanges.isEmpty) return VersionConstraint.empty; + if (newRanges.length == 1) return newRanges.single; + + return VersionUnion.fromRanges(newRanges); + } + + @override + VersionConstraint difference(VersionConstraint other) { + var ourRanges = ranges.iterator; + var theirRanges = _rangesFor(other).iterator; + + var newRanges = []; + ourRanges.moveNext(); + theirRanges.moveNext(); + var current = ourRanges.current; + + bool theirNextRange() { + if (theirRanges.moveNext()) return true; + + // If there are no more of their ranges, none of the rest of our ranges + // need to be subtracted so we can add them as-is. + newRanges.add(current); + while (ourRanges.moveNext()) { + newRanges.add(ourRanges.current); + } + return false; + } + + bool ourNextRange({bool includeCurrent = true}) { + if (includeCurrent) newRanges.add(current); + if (!ourRanges.moveNext()) return false; + current = ourRanges.current; + return true; + } + + for (;;) { + // If the current ranges are disjoint, move the lowest one forward. + if (strictlyLower(theirRanges.current, current)) { + if (!theirNextRange()) break; + continue; + } + + if (strictlyHigher(theirRanges.current, current)) { + if (!ourNextRange()) break; + continue; + } + + // If we're here, we know [theirRanges.current] overlaps [current]. + var difference = current.difference(theirRanges.current); + if (difference is VersionUnion) { + // If their range split [current] in half, we only need to continue + // checking future ranges against the latter half. + assert(difference.ranges.length == 2); + newRanges.add(difference.ranges.first); + current = difference.ranges.last; + + // Since their range split [current], it definitely doesn't allow higher + // versions, so we should move their ranges forward. + if (!theirNextRange()) break; + } else if (difference.isEmpty) { + if (!ourNextRange(includeCurrent: false)) break; + } else { + current = difference as VersionRange; + + // Move the constraint with the lower max value forward. This ensures + // that we keep both lists in sync as much as possible, and that large + // ranges have a chance to subtract or be subtracted by multiple small + // ranges that they contain. + if (allowsHigher(current, theirRanges.current)) { + if (!theirNextRange()) break; + } else { + if (!ourNextRange()) break; + } + } + } + + if (newRanges.isEmpty) return VersionConstraint.empty; + if (newRanges.length == 1) return newRanges.single; + return VersionUnion.fromRanges(newRanges); + } + + /// Returns [constraint] as a list of ranges. + /// + /// This is used to normalize ranges of various types. + List _rangesFor(VersionConstraint constraint) { + if (constraint.isEmpty) return []; + if (constraint is VersionUnion) return constraint.ranges; + if (constraint is VersionRange) return [constraint]; + throw ArgumentError('Unknown VersionConstraint type $constraint.'); + } + + @override + VersionConstraint union(VersionConstraint other) => + VersionConstraint.unionOf([this, other]); + + @override + bool operator ==(Object other) => + other is VersionUnion && + const ListEquality().equals(ranges, other.ranges); + + @override + int get hashCode => const ListEquality().hash(ranges); + + @override + String toString() => ranges.join(' or '); +} diff --git a/pkgs/pub_semver/pubspec.yaml b/pkgs/pub_semver/pubspec.yaml new file mode 100644 index 000000000..290fb9254 --- /dev/null +++ b/pkgs/pub_semver/pubspec.yaml @@ -0,0 +1,20 @@ +name: pub_semver +version: 2.1.5 +description: >- + Versions and version constraints implementing pub's versioning policy. This + is very similar to vanilla semver, with a few corner cases. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/pub_semver +topics: + - dart-pub + - semver + +environment: + sdk: ^3.4.0 + +dependencies: + collection: ^1.15.0 + meta: ^1.3.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + test: ^1.16.0 diff --git a/pkgs/pub_semver/test/utils.dart b/pkgs/pub_semver/test/utils.dart new file mode 100644 index 000000000..bd7aa8f8f --- /dev/null +++ b/pkgs/pub_semver/test/utils.dart @@ -0,0 +1,123 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +/// Some stock example versions to use in tests. +final v003 = Version.parse('0.0.3'); +final v010 = Version.parse('0.1.0'); +final v072 = Version.parse('0.7.2'); +final v080 = Version.parse('0.8.0'); +final v114 = Version.parse('1.1.4'); +final v123 = Version.parse('1.2.3'); +final v124 = Version.parse('1.2.4'); +final v130 = Version.parse('1.3.0'); +final v140 = Version.parse('1.4.0'); +final v200 = Version.parse('2.0.0'); +final v201 = Version.parse('2.0.1'); +final v234 = Version.parse('2.3.4'); +final v250 = Version.parse('2.5.0'); +final v300 = Version.parse('3.0.0'); + +/// A range that allows pre-release versions of its max version. +final includeMaxPreReleaseRange = + VersionRange(max: v200, alwaysIncludeMaxPreRelease: true); + +/// A [Matcher] that tests if a [VersionConstraint] allows or does not allow a +/// given list of [Version]s. +class _VersionConstraintMatcher implements Matcher { + final List _expected; + final bool _allow; + + _VersionConstraintMatcher(this._expected, this._allow); + + @override + bool matches(dynamic item, Map matchState) => + (item is VersionConstraint) && + _expected.every((version) => item.allows(version) == _allow); + + @override + Description describe(Description description) { + description.addAll(' ${_allow ? "allows" : "does not allow"} versions ', + ', ', '', _expected); + return description; + } + + @override + Description describeMismatch(dynamic item, Description mismatchDescription, + Map matchState, bool verbose) { + if (item is! VersionConstraint) { + mismatchDescription.add('was not a VersionConstraint'); + return mismatchDescription; + } + + var first = true; + for (var version in _expected) { + if (item.allows(version) != _allow) { + if (first) { + if (_allow) { + mismatchDescription.addDescriptionOf(item).add(' did not allow '); + } else { + mismatchDescription.addDescriptionOf(item).add(' allowed '); + } + } else { + mismatchDescription.add(' and '); + } + first = false; + + mismatchDescription.add(version.toString()); + } + } + + return mismatchDescription; + } +} + +/// Gets a [Matcher] that validates that a [VersionConstraint] allows all +/// given versions. +Matcher allows(Version v1, + [Version? v2, + Version? v3, + Version? v4, + Version? v5, + Version? v6, + Version? v7, + Version? v8]) { + var versions = _makeVersionList(v1, v2, v3, v4, v5, v6, v7, v8); + return _VersionConstraintMatcher(versions, true); +} + +/// Gets a [Matcher] that validates that a [VersionConstraint] allows none of +/// the given versions. +Matcher doesNotAllow(Version v1, + [Version? v2, + Version? v3, + Version? v4, + Version? v5, + Version? v6, + Version? v7, + Version? v8]) { + var versions = _makeVersionList(v1, v2, v3, v4, v5, v6, v7, v8); + return _VersionConstraintMatcher(versions, false); +} + +List _makeVersionList(Version v1, + [Version? v2, + Version? v3, + Version? v4, + Version? v5, + Version? v6, + Version? v7, + Version? v8]) { + var versions = [v1]; + if (v2 != null) versions.add(v2); + if (v3 != null) versions.add(v3); + if (v4 != null) versions.add(v4); + if (v5 != null) versions.add(v5); + if (v6 != null) versions.add(v6); + if (v7 != null) versions.add(v7); + if (v8 != null) versions.add(v8); + return versions; +} diff --git a/pkgs/pub_semver/test/version_constraint_test.dart b/pkgs/pub_semver/test/version_constraint_test.dart new file mode 100644 index 000000000..4fbcbe0cb --- /dev/null +++ b/pkgs/pub_semver/test/version_constraint_test.dart @@ -0,0 +1,185 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + test('any', () { + expect(VersionConstraint.any.isAny, isTrue); + expect( + VersionConstraint.any, + allows(Version.parse('0.0.0-blah'), Version.parse('1.2.3'), + Version.parse('12345.678.90'))); + }); + + test('empty', () { + expect(VersionConstraint.empty.isEmpty, isTrue); + expect(VersionConstraint.empty.isAny, isFalse); + expect( + VersionConstraint.empty, + doesNotAllow(Version.parse('0.0.0-blah'), Version.parse('1.2.3'), + Version.parse('12345.678.90'))); + }); + + group('parse()', () { + test('parses an exact version', () { + var constraint = VersionConstraint.parse('1.2.3-alpha'); + + expect(constraint is Version, isTrue); + expect(constraint, equals(Version(1, 2, 3, pre: 'alpha'))); + }); + + test('parses "any"', () { + var constraint = VersionConstraint.parse('any'); + + expect( + constraint, + allows(Version.parse('0.0.0'), Version.parse('1.2.3'), + Version.parse('12345.678.90'))); + }); + + test('parses a ">" minimum version', () { + var constraint = VersionConstraint.parse('>1.2.3'); + + expect(constraint, + allows(Version.parse('1.2.3+foo'), Version.parse('1.2.4'))); + expect( + constraint, + doesNotAllow(Version.parse('1.2.1'), Version.parse('1.2.3-build'), + Version.parse('1.2.3'))); + }); + + test('parses a ">=" minimum version', () { + var constraint = VersionConstraint.parse('>=1.2.3'); + + expect( + constraint, + allows(Version.parse('1.2.3'), Version.parse('1.2.3+foo'), + Version.parse('1.2.4'))); + expect(constraint, + doesNotAllow(Version.parse('1.2.1'), Version.parse('1.2.3-build'))); + }); + + test('parses a "<" maximum version', () { + var constraint = VersionConstraint.parse('<1.2.3'); + + expect(constraint, + allows(Version.parse('1.2.1'), Version.parse('1.2.2+foo'))); + expect( + constraint, + doesNotAllow(Version.parse('1.2.3'), Version.parse('1.2.3+foo'), + Version.parse('1.2.4'))); + }); + + test('parses a "<=" maximum version', () { + var constraint = VersionConstraint.parse('<=1.2.3'); + + expect( + constraint, + allows(Version.parse('1.2.1'), Version.parse('1.2.3-build'), + Version.parse('1.2.3'))); + expect(constraint, + doesNotAllow(Version.parse('1.2.3+foo'), Version.parse('1.2.4'))); + }); + + test('parses a series of space-separated constraints', () { + var constraint = VersionConstraint.parse('>1.0.0 >=1.2.3 <1.3.0'); + + expect( + constraint, allows(Version.parse('1.2.3'), Version.parse('1.2.5'))); + expect( + constraint, + doesNotAllow(Version.parse('1.2.3-pre'), Version.parse('1.3.0'), + Version.parse('3.4.5'))); + }); + + test('parses a pre-release-only constraint', () { + var constraint = VersionConstraint.parse('>=1.0.0-dev.2 <1.0.0'); + expect(constraint, + allows(Version.parse('1.0.0-dev.2'), Version.parse('1.0.0-dev.3'))); + expect(constraint, + doesNotAllow(Version.parse('1.0.0-dev.1'), Version.parse('1.0.0'))); + }); + + test('ignores whitespace around comparison operators', () { + var constraint = VersionConstraint.parse(' >1.0.0>=1.2.3 < 1.3.0'); + + expect( + constraint, allows(Version.parse('1.2.3'), Version.parse('1.2.5'))); + expect( + constraint, + doesNotAllow(Version.parse('1.2.3-pre'), Version.parse('1.3.0'), + Version.parse('3.4.5'))); + }); + + test('does not allow "any" to be mixed with other constraints', () { + expect(() => VersionConstraint.parse('any 1.0.0'), throwsFormatException); + }); + + test('parses a "^" version', () { + expect(VersionConstraint.parse('^0.0.3'), + equals(VersionConstraint.compatibleWith(v003))); + + expect(VersionConstraint.parse('^0.7.2'), + equals(VersionConstraint.compatibleWith(v072))); + + expect(VersionConstraint.parse('^1.2.3'), + equals(VersionConstraint.compatibleWith(v123))); + + var min = Version.parse('0.7.2-pre+1'); + expect(VersionConstraint.parse('^0.7.2-pre+1'), + equals(VersionConstraint.compatibleWith(min))); + }); + + test('does not allow "^" to be mixed with other constraints', () { + expect(() => VersionConstraint.parse('>=1.2.3 ^1.0.0'), + throwsFormatException); + expect(() => VersionConstraint.parse('^1.0.0 <1.2.3'), + throwsFormatException); + }); + + test('ignores whitespace around "^"', () { + var constraint = VersionConstraint.parse(' ^ 1.2.3 '); + + expect(constraint, equals(VersionConstraint.compatibleWith(v123))); + }); + + test('throws FormatException on a bad string', () { + var bad = [ + '', ' ', // Empty string. + 'foo', // Bad text. + '>foo', // Bad text after operator. + '^foo', // Bad text after "^". + '1.0.0 foo', '1.0.0foo', // Bad text after version. + 'anything', // Bad text after "any". + '<>1.0.0', // Multiple operators. + '1.0.0<' // Trailing operator. + ]; + + for (var text in bad) { + expect(() => VersionConstraint.parse(text), throwsFormatException); + } + }); + }); + + group('compatibleWith()', () { + test('returns the range of compatible versions', () { + var constraint = VersionConstraint.compatibleWith(v072); + + expect( + constraint, + equals(VersionRange( + min: v072, includeMin: true, max: v072.nextBreaking))); + }); + + test('toString() uses "^"', () { + var constraint = VersionConstraint.compatibleWith(v072); + + expect(constraint.toString(), equals('^0.7.2')); + }); + }); +} diff --git a/pkgs/pub_semver/test/version_range_test.dart b/pkgs/pub_semver/test/version_range_test.dart new file mode 100644 index 000000000..5978df0c7 --- /dev/null +++ b/pkgs/pub_semver/test/version_range_test.dart @@ -0,0 +1,998 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + group('constructor', () { + test('takes a min and max', () { + var range = VersionRange(min: v123, max: v124); + expect(range.isAny, isFalse); + expect(range.min, equals(v123)); + expect(range.max, equals(v124.firstPreRelease)); + }); + + group("doesn't make the max a pre-release if", () { + test("it's already a pre-release", () { + expect(VersionRange(max: Version.parse('1.2.4-pre')).max, + equals(Version.parse('1.2.4-pre'))); + }); + + test('includeMax is true', () { + expect(VersionRange(max: v124, includeMax: true).max, equals(v124)); + }); + + test('min is a prerelease of max', () { + expect(VersionRange(min: Version.parse('1.2.4-pre'), max: v124).max, + equals(v124)); + }); + + test('max has a build identifier', () { + expect(VersionRange(max: Version.parse('1.2.4+1')).max, + equals(Version.parse('1.2.4+1'))); + }); + }); + + test('allows omitting max', () { + var range = VersionRange(min: v123); + expect(range.isAny, isFalse); + expect(range.min, equals(v123)); + expect(range.max, isNull); + }); + + test('allows omitting min and max', () { + var range = VersionRange(); + expect(range.isAny, isTrue); + expect(range.min, isNull); + expect(range.max, isNull); + }); + + test('takes includeMin', () { + var range = VersionRange(min: v123, includeMin: true); + expect(range.includeMin, isTrue); + }); + + test('includeMin defaults to false if omitted', () { + var range = VersionRange(min: v123); + expect(range.includeMin, isFalse); + }); + + test('takes includeMax', () { + var range = VersionRange(max: v123, includeMax: true); + expect(range.includeMax, isTrue); + }); + + test('includeMax defaults to false if omitted', () { + var range = VersionRange(max: v123); + expect(range.includeMax, isFalse); + }); + + test('throws if min > max', () { + expect(() => VersionRange(min: v124, max: v123), throwsArgumentError); + }); + }); + + group('allows()', () { + test('version must be greater than min', () { + var range = VersionRange(min: v123); + + expect(range, allows(Version.parse('1.3.3'), Version.parse('2.3.3'))); + expect( + range, doesNotAllow(Version.parse('1.2.2'), Version.parse('1.2.3'))); + }); + + test('version must be min or greater if includeMin', () { + var range = VersionRange(min: v123, includeMin: true); + + expect( + range, + allows(Version.parse('1.2.3'), Version.parse('1.3.3'), + Version.parse('2.3.3'))); + expect(range, doesNotAllow(Version.parse('1.2.2'))); + }); + + test('pre-release versions of inclusive min are excluded', () { + var range = VersionRange(min: v123, includeMin: true); + + expect(range, allows(Version.parse('1.2.4-dev'))); + expect(range, doesNotAllow(Version.parse('1.2.3-dev'))); + }); + + test('version must be less than max', () { + var range = VersionRange(max: v234); + + expect(range, allows(Version.parse('2.3.3'))); + expect( + range, doesNotAllow(Version.parse('2.3.4'), Version.parse('2.4.3'))); + }); + + test('pre-release versions of non-pre-release max are excluded', () { + var range = VersionRange(max: v234); + + expect(range, allows(Version.parse('2.3.3'))); + expect(range, + doesNotAllow(Version.parse('2.3.4-dev'), Version.parse('2.3.4'))); + }); + + test( + 'pre-release versions of non-pre-release max are included if min is a ' + 'pre-release of the same version', () { + var range = VersionRange(min: Version.parse('2.3.4-dev.0'), max: v234); + + expect(range, allows(Version.parse('2.3.4-dev.1'))); + expect( + range, + doesNotAllow(Version.parse('2.3.3'), Version.parse('2.3.4-dev'), + Version.parse('2.3.4'))); + }); + + test('pre-release versions of pre-release max are included', () { + var range = VersionRange(max: Version.parse('2.3.4-dev.2')); + + expect(range, allows(Version.parse('2.3.4-dev.1'))); + expect( + range, + doesNotAllow( + Version.parse('2.3.4-dev.2'), Version.parse('2.3.4-dev.3'))); + }); + + test('version must be max or less if includeMax', () { + var range = VersionRange(min: v123, max: v234, includeMax: true); + + expect( + range, + allows( + Version.parse('2.3.3'), + Version.parse('2.3.4'), + // Pre-releases of the max are allowed. + Version.parse('2.3.4-dev'))); + expect(range, doesNotAllow(Version.parse('2.4.3'))); + }); + + test('has no min if one was not set', () { + var range = VersionRange(max: v123); + + expect(range, allows(Version.parse('0.0.0'))); + expect(range, doesNotAllow(Version.parse('1.2.3'))); + }); + + test('has no max if one was not set', () { + var range = VersionRange(min: v123); + + expect(range, allows(Version.parse('1.3.3'), Version.parse('999.3.3'))); + expect(range, doesNotAllow(Version.parse('1.2.3'))); + }); + + test('allows any version if there is no min or max', () { + var range = VersionRange(); + + expect(range, allows(Version.parse('0.0.0'), Version.parse('999.99.9'))); + }); + + test('allows pre-releases of the max with includeMaxPreRelease', () { + expect(includeMaxPreReleaseRange, allows(Version.parse('2.0.0-dev'))); + }); + }); + + group('allowsAll()', () { + test('allows an empty constraint', () { + expect( + VersionRange(min: v123, max: v250).allowsAll(VersionConstraint.empty), + isTrue); + }); + + test('allows allowed versions', () { + var range = VersionRange(min: v123, max: v250, includeMax: true); + expect(range.allowsAll(v123), isFalse); + expect(range.allowsAll(v124), isTrue); + expect(range.allowsAll(v250), isTrue); + expect(range.allowsAll(v300), isFalse); + }); + + test('with no min', () { + var range = VersionRange(max: v250); + expect(range.allowsAll(VersionRange(min: v080, max: v140)), isTrue); + expect(range.allowsAll(VersionRange(min: v080, max: v300)), isFalse); + expect(range.allowsAll(VersionRange(max: v140)), isTrue); + expect(range.allowsAll(VersionRange(max: v300)), isFalse); + expect(range.allowsAll(range), isTrue); + expect(range.allowsAll(VersionConstraint.any), isFalse); + }); + + test('with no max', () { + var range = VersionRange(min: v010); + expect(range.allowsAll(VersionRange(min: v080, max: v140)), isTrue); + expect(range.allowsAll(VersionRange(min: v003, max: v140)), isFalse); + expect(range.allowsAll(VersionRange(min: v080)), isTrue); + expect(range.allowsAll(VersionRange(min: v003)), isFalse); + expect(range.allowsAll(range), isTrue); + expect(range.allowsAll(VersionConstraint.any), isFalse); + }); + + test('with a min and max', () { + var range = VersionRange(min: v010, max: v250); + expect(range.allowsAll(VersionRange(min: v080, max: v140)), isTrue); + expect(range.allowsAll(VersionRange(min: v080, max: v300)), isFalse); + expect(range.allowsAll(VersionRange(min: v003, max: v140)), isFalse); + expect(range.allowsAll(VersionRange(min: v080)), isFalse); + expect(range.allowsAll(VersionRange(max: v140)), isFalse); + expect(range.allowsAll(range), isTrue); + }); + + test("allows a bordering range that's not more inclusive", () { + var exclusive = VersionRange(min: v010, max: v250); + var inclusive = VersionRange( + min: v010, includeMin: true, max: v250, includeMax: true); + expect(inclusive.allowsAll(exclusive), isTrue); + expect(inclusive.allowsAll(inclusive), isTrue); + expect(exclusive.allowsAll(inclusive), isFalse); + expect(exclusive.allowsAll(exclusive), isTrue); + }); + + test('allows unions that are completely contained', () { + var range = VersionRange(min: v114, max: v200); + expect(range.allowsAll(VersionRange(min: v123, max: v124).union(v140)), + isTrue); + expect(range.allowsAll(VersionRange(min: v010, max: v124).union(v140)), + isFalse); + expect(range.allowsAll(VersionRange(min: v123, max: v234).union(v140)), + isFalse); + }); + + group('pre-release versions', () { + test('of inclusive min are excluded', () { + var range = VersionRange(min: v123, includeMin: true); + + expect(range.allowsAll(VersionConstraint.parse('>1.2.4-dev')), isTrue); + expect(range.allowsAll(VersionConstraint.parse('>1.2.3-dev')), isFalse); + }); + + test('of non-pre-release max are excluded', () { + var range = VersionRange(max: v234); + + expect(range.allowsAll(VersionConstraint.parse('<2.3.3')), isTrue); + expect(range.allowsAll(VersionConstraint.parse('<2.3.4-dev')), isFalse); + }); + + test('of non-pre-release max are included with includeMaxPreRelease', () { + expect( + includeMaxPreReleaseRange + .allowsAll(VersionConstraint.parse('<2.0.0-dev')), + isTrue); + }); + + test( + 'of non-pre-release max are included if min is a pre-release of the ' + 'same version', () { + var range = VersionRange(min: Version.parse('2.3.4-dev.0'), max: v234); + + expect( + range.allowsAll( + VersionConstraint.parse('>2.3.4-dev.0 <2.3.4-dev.1')), + isTrue); + }); + + test('of pre-release max are included', () { + var range = VersionRange(max: Version.parse('2.3.4-dev.2')); + + expect( + range.allowsAll(VersionConstraint.parse('<2.3.4-dev.1')), isTrue); + expect( + range.allowsAll(VersionConstraint.parse('<2.3.4-dev.2')), isTrue); + expect( + range.allowsAll(VersionConstraint.parse('<=2.3.4-dev.2')), isFalse); + expect( + range.allowsAll(VersionConstraint.parse('<2.3.4-dev.3')), isFalse); + }); + }); + }); + + group('allowsAny()', () { + test('disallows an empty constraint', () { + expect( + VersionRange(min: v123, max: v250).allowsAny(VersionConstraint.empty), + isFalse); + }); + + test('allows allowed versions', () { + var range = VersionRange(min: v123, max: v250, includeMax: true); + expect(range.allowsAny(v123), isFalse); + expect(range.allowsAny(v124), isTrue); + expect(range.allowsAny(v250), isTrue); + expect(range.allowsAny(v300), isFalse); + }); + + test('with no min', () { + var range = VersionRange(max: v200); + expect(range.allowsAny(VersionRange(min: v140, max: v300)), isTrue); + expect(range.allowsAny(VersionRange(min: v234, max: v300)), isFalse); + expect(range.allowsAny(VersionRange(min: v140)), isTrue); + expect(range.allowsAny(VersionRange(min: v234)), isFalse); + expect(range.allowsAny(range), isTrue); + }); + + test('with no max', () { + var range = VersionRange(min: v072); + expect(range.allowsAny(VersionRange(min: v003, max: v140)), isTrue); + expect(range.allowsAny(VersionRange(min: v003, max: v010)), isFalse); + expect(range.allowsAny(VersionRange(max: v080)), isTrue); + expect(range.allowsAny(VersionRange(max: v003)), isFalse); + expect(range.allowsAny(range), isTrue); + }); + + test('with a min and max', () { + var range = VersionRange(min: v072, max: v200); + expect(range.allowsAny(VersionRange(min: v003, max: v140)), isTrue); + expect(range.allowsAny(VersionRange(min: v140, max: v300)), isTrue); + expect(range.allowsAny(VersionRange(min: v003, max: v010)), isFalse); + expect(range.allowsAny(VersionRange(min: v234, max: v300)), isFalse); + expect(range.allowsAny(VersionRange(max: v010)), isFalse); + expect(range.allowsAny(VersionRange(min: v234)), isFalse); + expect(range.allowsAny(range), isTrue); + }); + + test('allows a bordering range when both are inclusive', () { + expect( + VersionRange(max: v250).allowsAny(VersionRange(min: v250)), isFalse); + + expect( + VersionRange(max: v250, includeMax: true) + .allowsAny(VersionRange(min: v250)), + isFalse); + + expect( + VersionRange(max: v250) + .allowsAny(VersionRange(min: v250, includeMin: true)), + isFalse); + + expect( + VersionRange(max: v250, includeMax: true) + .allowsAny(VersionRange(min: v250, includeMin: true)), + isTrue); + + expect( + VersionRange(min: v250).allowsAny(VersionRange(max: v250)), isFalse); + + expect( + VersionRange(min: v250, includeMin: true) + .allowsAny(VersionRange(max: v250)), + isFalse); + + expect( + VersionRange(min: v250) + .allowsAny(VersionRange(max: v250, includeMax: true)), + isFalse); + + expect( + VersionRange(min: v250, includeMin: true) + .allowsAny(VersionRange(max: v250, includeMax: true)), + isTrue); + }); + + test('allows unions that are partially contained', () { + var range = VersionRange(min: v114, max: v200); + expect(range.allowsAny(VersionRange(min: v010, max: v080).union(v140)), + isTrue); + expect(range.allowsAny(VersionRange(min: v123, max: v234).union(v300)), + isTrue); + expect(range.allowsAny(VersionRange(min: v234, max: v300).union(v010)), + isFalse); + }); + + group('pre-release versions', () { + test('of inclusive min are excluded', () { + var range = VersionRange(min: v123, includeMin: true); + + expect(range.allowsAny(VersionConstraint.parse('<1.2.4-dev')), isTrue); + expect(range.allowsAny(VersionConstraint.parse('<1.2.3-dev')), isFalse); + }); + + test('of non-pre-release max are excluded', () { + var range = VersionRange(max: v234); + + expect(range.allowsAny(VersionConstraint.parse('>2.3.3')), isTrue); + expect(range.allowsAny(VersionConstraint.parse('>2.3.4-dev')), isFalse); + }); + + test('of non-pre-release max are included with includeMaxPreRelease', () { + expect( + includeMaxPreReleaseRange + .allowsAny(VersionConstraint.parse('>2.0.0-dev')), + isTrue); + }); + + test( + 'of non-pre-release max are included if min is a pre-release of the ' + 'same version', () { + var range = VersionRange(min: Version.parse('2.3.4-dev.0'), max: v234); + + expect( + range.allowsAny(VersionConstraint.parse('>2.3.4-dev.1')), isTrue); + expect(range.allowsAny(VersionConstraint.parse('>2.3.4')), isFalse); + + expect( + range.allowsAny(VersionConstraint.parse('<2.3.4-dev.1')), isTrue); + expect(range.allowsAny(VersionConstraint.parse('<2.3.4-dev')), isFalse); + }); + + test('of pre-release max are included', () { + var range = VersionConstraint.parse('<2.3.4-dev.2'); + + expect( + range.allowsAny(VersionConstraint.parse('>2.3.4-dev.1')), isTrue); + expect( + range.allowsAny(VersionConstraint.parse('>2.3.4-dev.2')), isFalse); + expect( + range.allowsAny(VersionConstraint.parse('>2.3.4-dev.3')), isFalse); + }); + }); + }); + + group('intersect()', () { + test('two overlapping ranges', () { + expect( + VersionRange(min: v123, max: v250) + .intersect(VersionRange(min: v200, max: v300)), + equals(VersionRange(min: v200, max: v250))); + }); + + test('a non-overlapping range allows no versions', () { + var a = VersionRange(min: v114, max: v124); + var b = VersionRange(min: v200, max: v250); + expect(a.intersect(b).isEmpty, isTrue); + }); + + test('adjacent ranges allow no versions if exclusive', () { + var a = VersionRange(min: v114, max: v124); + var b = VersionRange(min: v124, max: v200); + expect(a.intersect(b).isEmpty, isTrue); + }); + + test('adjacent ranges allow version if inclusive', () { + var a = VersionRange(min: v114, max: v124, includeMax: true); + var b = VersionRange(min: v124, max: v200, includeMin: true); + expect(a.intersect(b), equals(v124)); + }); + + test('with an open range', () { + var open = VersionRange(); + var a = VersionRange(min: v114, max: v124); + expect(open.intersect(open), equals(open)); + expect(a.intersect(open), equals(a)); + }); + + test('returns the version if the range allows it', () { + expect(VersionRange(min: v114, max: v124).intersect(v123), equals(v123)); + expect( + VersionRange(min: v123, max: v124).intersect(v114).isEmpty, isTrue); + }); + + test('with a range with a pre-release min, returns an empty constraint', + () { + expect( + VersionRange(max: v200) + .intersect(VersionConstraint.parse('>=2.0.0-dev')), + equals(VersionConstraint.empty)); + }); + + test('with a range with a pre-release max, returns the original', () { + expect( + VersionRange(max: v200) + .intersect(VersionConstraint.parse('<2.0.0-dev')), + equals(VersionRange(max: v200))); + }); + + group('with includeMaxPreRelease', () { + test('preserves includeMaxPreRelease if the max version is included', () { + expect( + includeMaxPreReleaseRange + .intersect(VersionConstraint.parse('<1.0.0')), + equals(VersionConstraint.parse('<1.0.0'))); + expect( + includeMaxPreReleaseRange + .intersect(VersionConstraint.parse('<2.0.0')), + equals(VersionConstraint.parse('<2.0.0'))); + expect(includeMaxPreReleaseRange.intersect(includeMaxPreReleaseRange), + equals(includeMaxPreReleaseRange)); + expect( + includeMaxPreReleaseRange + .intersect(VersionConstraint.parse('<3.0.0')), + equals(includeMaxPreReleaseRange)); + expect( + includeMaxPreReleaseRange + .intersect(VersionConstraint.parse('>1.1.4')), + equals(VersionRange( + min: v114, max: v200, alwaysIncludeMaxPreRelease: true))); + }); + + test( + 'and a range with a pre-release min, returns ' + 'an intersection', () { + expect( + includeMaxPreReleaseRange + .intersect(VersionConstraint.parse('>=2.0.0-dev')), + equals(VersionConstraint.parse('>=2.0.0-dev <2.0.0'))); + }); + + test( + 'and a range with a pre-release max, returns ' + 'the narrower constraint', () { + expect( + includeMaxPreReleaseRange + .intersect(VersionConstraint.parse('<2.0.0-dev')), + equals(VersionConstraint.parse('<2.0.0-dev'))); + }); + }); + }); + + group('union()', () { + test('with a version returns the range if it contains the version', () { + var range = VersionRange(min: v114, max: v124); + expect(range.union(v123), equals(range)); + }); + + test('with a version on the edge of the range, expands the range', () { + expect( + VersionRange(min: v114, max: v124, alwaysIncludeMaxPreRelease: true) + .union(v124), + equals(VersionRange(min: v114, max: v124, includeMax: true))); + expect(VersionRange(min: v114, max: v124).union(v114), + equals(VersionRange(min: v114, max: v124, includeMin: true))); + }); + + test( + 'with a version allows both the range and the version if the range ' + "doesn't contain the version", () { + var result = VersionRange(min: v003, max: v114).union(v124); + expect(result, allows(v010)); + expect(result, doesNotAllow(v123)); + expect(result, allows(v124)); + }); + + test('returns a VersionUnion for a disjoint range', () { + var result = VersionRange(min: v003, max: v114) + .union(VersionRange(min: v130, max: v200)); + expect(result, allows(v080)); + expect(result, doesNotAllow(v123)); + expect(result, allows(v140)); + }); + + test('returns a VersionUnion for a disjoint range with infinite end', () { + void isVersionUnion(VersionConstraint constraint) { + expect(constraint, allows(v080)); + expect(constraint, doesNotAllow(v123)); + expect(constraint, allows(v140)); + } + + for (final includeAMin in [true, false]) { + for (final includeAMax in [true, false]) { + for (final includeBMin in [true, false]) { + for (final includeBMax in [true, false]) { + final a = VersionRange( + min: v130, includeMin: includeAMin, includeMax: includeAMax); + final b = VersionRange( + max: v114, includeMin: includeBMin, includeMax: includeBMax); + isVersionUnion(a.union(b)); + isVersionUnion(b.union(a)); + } + } + } + } + }); + + test('considers open ranges disjoint', () { + var result = VersionRange(min: v003, max: v114) + .union(VersionRange(min: v114, max: v200)); + expect(result, allows(v080)); + expect(result, doesNotAllow(v114)); + expect(result, allows(v140)); + + result = VersionRange(min: v114, max: v200) + .union(VersionRange(min: v003, max: v114)); + expect(result, allows(v080)); + expect(result, doesNotAllow(v114)); + expect(result, allows(v140)); + }); + + test('returns a merged range for an overlapping range', () { + var result = VersionRange(min: v003, max: v114) + .union(VersionRange(min: v080, max: v200)); + expect(result, equals(VersionRange(min: v003, max: v200))); + }); + + test('considers closed ranges overlapping', () { + var result = VersionRange(min: v003, max: v114, includeMax: true) + .union(VersionRange(min: v114, max: v200)); + expect(result, equals(VersionRange(min: v003, max: v200))); + + result = + VersionRange(min: v003, max: v114, alwaysIncludeMaxPreRelease: true) + .union(VersionRange(min: v114, max: v200, includeMin: true)); + expect(result, equals(VersionRange(min: v003, max: v200))); + + result = VersionRange(min: v114, max: v200) + .union(VersionRange(min: v003, max: v114, includeMax: true)); + expect(result, equals(VersionRange(min: v003, max: v200))); + + result = VersionRange(min: v114, max: v200, includeMin: true).union( + VersionRange(min: v003, max: v114, alwaysIncludeMaxPreRelease: true)); + expect(result, equals(VersionRange(min: v003, max: v200))); + }); + + test('includes edges if either range does', () { + var result = VersionRange(min: v003, max: v114, includeMin: true) + .union(VersionRange(min: v003, max: v114, includeMax: true)); + expect( + result, + equals(VersionRange( + min: v003, max: v114, includeMin: true, includeMax: true))); + }); + + test('with a range with a pre-release min, returns a constraint with a gap', + () { + var result = + VersionRange(max: v200).union(VersionConstraint.parse('>=2.0.0-dev')); + expect(result, allows(v140)); + expect(result, doesNotAllow(Version.parse('2.0.0-alpha'))); + expect(result, allows(Version.parse('2.0.0-dev'))); + expect(result, allows(Version.parse('2.0.0-dev.1'))); + expect(result, allows(Version.parse('2.0.0'))); + }); + + test('with a range with a pre-release max, returns the larger constraint', + () { + expect( + VersionRange(max: v200).union(VersionConstraint.parse('<2.0.0-dev')), + equals(VersionConstraint.parse('<2.0.0-dev'))); + }); + + group('with includeMaxPreRelease', () { + test('adds includeMaxPreRelease if the max version is included', () { + expect( + includeMaxPreReleaseRange.union(VersionConstraint.parse('<1.0.0')), + equals(includeMaxPreReleaseRange)); + expect(includeMaxPreReleaseRange.union(includeMaxPreReleaseRange), + equals(includeMaxPreReleaseRange)); + expect( + includeMaxPreReleaseRange.union(VersionConstraint.parse('<2.0.0')), + equals(includeMaxPreReleaseRange)); + expect( + includeMaxPreReleaseRange.union(VersionConstraint.parse('<3.0.0')), + equals(VersionConstraint.parse('<3.0.0'))); + }); + + test('and a range with a pre-release min, returns any', () { + expect( + includeMaxPreReleaseRange + .union(VersionConstraint.parse('>=2.0.0-dev')), + equals(VersionConstraint.any)); + }); + + test('and a range with a pre-release max, returns the original', () { + expect( + includeMaxPreReleaseRange + .union(VersionConstraint.parse('<2.0.0-dev')), + equals(includeMaxPreReleaseRange)); + }); + }); + }); + + group('difference()', () { + test('with an empty range returns the original range', () { + expect( + VersionRange(min: v003, max: v114) + .difference(VersionConstraint.empty), + equals(VersionRange(min: v003, max: v114))); + }); + + test('with a version outside the range returns the original range', () { + expect(VersionRange(min: v003, max: v114).difference(v200), + equals(VersionRange(min: v003, max: v114))); + }); + + test('with a version in the range splits the range', () { + expect( + VersionRange(min: v003, max: v114).difference(v072), + equals(VersionConstraint.unionOf([ + VersionRange( + min: v003, max: v072, alwaysIncludeMaxPreRelease: true), + VersionRange(min: v072, max: v114) + ]))); + }); + + test('with the max version makes the max exclusive', () { + expect( + VersionRange(min: v003, max: v114, includeMax: true).difference(v114), + equals(VersionRange( + min: v003, max: v114, alwaysIncludeMaxPreRelease: true))); + }); + + test('with the min version makes the min exclusive', () { + expect( + VersionRange(min: v003, max: v114, includeMin: true).difference(v003), + equals(VersionRange(min: v003, max: v114))); + }); + + test('with a disjoint range returns the original', () { + expect( + VersionRange(min: v003, max: v114) + .difference(VersionRange(min: v123, max: v140)), + equals(VersionRange(min: v003, max: v114))); + }); + + test('with an adjacent range returns the original', () { + expect( + VersionRange(min: v003, max: v114, includeMax: true) + .difference(VersionRange(min: v114, max: v140)), + equals(VersionRange(min: v003, max: v114, includeMax: true))); + }); + + test('with a range at the beginning cuts off the beginning of the range', + () { + expect( + VersionRange(min: v080, max: v130) + .difference(VersionRange(min: v010, max: v114)), + equals(VersionConstraint.parse('>=1.1.4-0 <1.3.0'))); + expect( + VersionRange(min: v080, max: v130) + .difference(VersionRange(max: v114)), + equals(VersionConstraint.parse('>=1.1.4-0 <1.3.0'))); + expect( + VersionRange(min: v080, max: v130) + .difference(VersionRange(min: v010, max: v114, includeMax: true)), + equals(VersionRange(min: v114, max: v130))); + expect( + VersionRange(min: v080, max: v130, includeMin: true) + .difference(VersionRange(min: v010, max: v080, includeMax: true)), + equals(VersionRange(min: v080, max: v130))); + expect( + VersionRange(min: v080, max: v130, includeMax: true) + .difference(VersionRange(min: v080, max: v130)), + equals(VersionConstraint.parse('>=1.3.0-0 <=1.3.0'))); + }); + + test('with a range at the end cuts off the end of the range', () { + expect( + VersionRange(min: v080, max: v130) + .difference(VersionRange(min: v114, max: v140)), + equals(VersionRange(min: v080, max: v114, includeMax: true))); + expect( + VersionRange(min: v080, max: v130) + .difference(VersionRange(min: v114)), + equals(VersionRange(min: v080, max: v114, includeMax: true))); + expect( + VersionRange(min: v080, max: v130) + .difference(VersionRange(min: v114, max: v140, includeMin: true)), + equals(VersionRange( + min: v080, max: v114, alwaysIncludeMaxPreRelease: true))); + expect( + VersionRange(min: v080, max: v130, includeMax: true) + .difference(VersionRange(min: v130, max: v140, includeMin: true)), + equals(VersionRange( + min: v080, max: v130, alwaysIncludeMaxPreRelease: true))); + expect( + VersionRange(min: v080, max: v130, includeMin: true) + .difference(VersionRange(min: v080, max: v130)), + equals(v080)); + }); + + test('with a range in the middle cuts the range in half', () { + expect( + VersionRange(min: v003, max: v130) + .difference(VersionRange(min: v072, max: v114)), + equals(VersionConstraint.unionOf([ + VersionRange(min: v003, max: v072, includeMax: true), + VersionConstraint.parse('>=1.1.4-0 <1.3.0') + ]))); + }); + + test('with a totally covering range returns empty', () { + expect( + VersionRange(min: v114, max: v200) + .difference(VersionRange(min: v072, max: v300)), + isEmpty); + expect( + VersionRange(min: v003, max: v114) + .difference(VersionRange(min: v003, max: v114)), + isEmpty); + expect( + VersionRange(min: v003, max: v114, includeMin: true, includeMax: true) + .difference(VersionRange( + min: v003, max: v114, includeMin: true, includeMax: true)), + isEmpty); + }); + + test( + "with a version union that doesn't cover the range, returns the " + 'original', () { + expect( + VersionRange(min: v114, max: v140) + .difference(VersionConstraint.unionOf([v010, v200])), + equals(VersionRange(min: v114, max: v140))); + }); + + test('with a version union that intersects the ends, chops them off', () { + expect( + VersionRange(min: v114, max: v140).difference( + VersionConstraint.unionOf([ + VersionRange(min: v080, max: v123), + VersionRange(min: v130, max: v200) + ])), + equals(VersionConstraint.parse('>=1.2.3-0 <=1.3.0'))); + }); + + test('with a version union that intersects the middle, chops it up', () { + expect( + VersionRange(min: v114, max: v140) + .difference(VersionConstraint.unionOf([v123, v124, v130])), + equals(VersionConstraint.unionOf([ + VersionRange( + min: v114, max: v123, alwaysIncludeMaxPreRelease: true), + VersionRange( + min: v123, max: v124, alwaysIncludeMaxPreRelease: true), + VersionRange( + min: v124, max: v130, alwaysIncludeMaxPreRelease: true), + VersionRange(min: v130, max: v140) + ]))); + }); + + test('with a version union that covers the whole range, returns empty', () { + expect( + VersionRange(min: v114, max: v140).difference( + VersionConstraint.unionOf([v003, VersionRange(min: v010)])), + equals(VersionConstraint.empty)); + }); + + test('with a range with a pre-release min, returns the original', () { + expect( + VersionRange(max: v200) + .difference(VersionConstraint.parse('>=2.0.0-dev')), + equals(VersionRange(max: v200))); + }); + + test('with a range with a pre-release max, returns null', () { + expect( + VersionRange(max: v200) + .difference(VersionConstraint.parse('<2.0.0-dev')), + equals(VersionConstraint.empty)); + }); + + group('with includeMaxPreRelease', () { + group('for the minuend', () { + test('preserves includeMaxPreRelease if the max version is included', + () { + expect( + includeMaxPreReleaseRange + .difference(VersionConstraint.parse('<1.0.0')), + equals(VersionRange( + min: Version.parse('1.0.0-0'), + max: v200, + includeMin: true, + alwaysIncludeMaxPreRelease: true))); + expect( + includeMaxPreReleaseRange + .difference(VersionConstraint.parse('<2.0.0')), + equals(VersionRange( + min: v200.firstPreRelease, + max: v200, + includeMin: true, + alwaysIncludeMaxPreRelease: true))); + expect( + includeMaxPreReleaseRange.difference(includeMaxPreReleaseRange), + equals(VersionConstraint.empty)); + expect( + includeMaxPreReleaseRange + .difference(VersionConstraint.parse('<3.0.0')), + equals(VersionConstraint.empty)); + }); + + test('with a range with a pre-release min, adjusts the max', () { + expect( + includeMaxPreReleaseRange + .difference(VersionConstraint.parse('>=2.0.0-dev')), + equals(VersionConstraint.parse('<2.0.0-dev'))); + }); + + test('with a range with a pre-release max, adjusts the min', () { + expect( + includeMaxPreReleaseRange + .difference(VersionConstraint.parse('<2.0.0-dev')), + equals(VersionConstraint.parse('>=2.0.0-dev <2.0.0'))); + }); + }); + + group('for the subtrahend', () { + group("doesn't create a pre-release minimum", () { + test('when cutting off the bottom', () { + expect( + VersionConstraint.parse('<3.0.0') + .difference(includeMaxPreReleaseRange), + equals(VersionRange(min: v200, max: v300, includeMin: true))); + }); + + test('with splitting down the middle', () { + expect( + VersionConstraint.parse('<4.0.0').difference(VersionRange( + min: v200, + max: v300, + includeMin: true, + alwaysIncludeMaxPreRelease: true)), + equals(VersionConstraint.unionOf([ + VersionRange(max: v200, alwaysIncludeMaxPreRelease: true), + VersionConstraint.parse('>=3.0.0 <4.0.0') + ]))); + }); + + test('can leave a single version', () { + expect( + VersionConstraint.parse('<=2.0.0') + .difference(includeMaxPreReleaseRange), + equals(v200)); + }); + }); + }); + }); + }); + + test('isEmpty', () { + expect(VersionRange().isEmpty, isFalse); + expect(VersionRange(min: v123, max: v124).isEmpty, isFalse); + }); + + group('compareTo()', () { + test('orders by minimum first', () { + _expectComparesSmaller(VersionRange(min: v003, max: v080), + VersionRange(min: v010, max: v072)); + _expectComparesSmaller(VersionRange(min: v003, max: v080), + VersionRange(min: v010, max: v080)); + _expectComparesSmaller(VersionRange(min: v003, max: v080), + VersionRange(min: v010, max: v114)); + }); + + test('orders by maximum second', () { + _expectComparesSmaller(VersionRange(min: v003, max: v010), + VersionRange(min: v003, max: v072)); + }); + + test('includeMin comes before !includeMin', () { + _expectComparesSmaller( + VersionRange(min: v003, max: v080, includeMin: true), + VersionRange(min: v003, max: v080)); + }); + + test('includeMax comes after !includeMax', () { + _expectComparesSmaller(VersionRange(min: v003, max: v080), + VersionRange(min: v003, max: v080, includeMax: true)); + }); + + test('includeMaxPreRelease comes after !includeMaxPreRelease', () { + _expectComparesSmaller( + VersionRange(max: v200), includeMaxPreReleaseRange); + }); + + test('no minimum comes before small minimum', () { + _expectComparesSmaller( + VersionRange(max: v010), VersionRange(min: v003, max: v010)); + _expectComparesSmaller(VersionRange(max: v010, includeMin: true), + VersionRange(min: v003, max: v010)); + }); + + test('no maximium comes after large maximum', () { + _expectComparesSmaller( + VersionRange(min: v003, max: v300), VersionRange(min: v003)); + _expectComparesSmaller(VersionRange(min: v003, max: v300), + VersionRange(min: v003, includeMax: true)); + }); + }); +} + +void _expectComparesSmaller(VersionRange smaller, VersionRange larger) { + expect(smaller.compareTo(larger), lessThan(0), + reason: 'expected $smaller to sort below $larger'); + expect(larger.compareTo(smaller), greaterThan(0), + reason: 'expected $larger to sort above $smaller'); +} diff --git a/pkgs/pub_semver/test/version_test.dart b/pkgs/pub_semver/test/version_test.dart new file mode 100644 index 000000000..d7f1197c8 --- /dev/null +++ b/pkgs/pub_semver/test/version_test.dart @@ -0,0 +1,411 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + test('none', () { + expect(Version.none.toString(), equals('0.0.0')); + }); + + test('prioritize()', () { + // A correctly sorted list of versions in order of increasing priority. + var versions = [ + '1.0.0-alpha', + '2.0.0-alpha', + '1.0.0', + '1.0.0+build', + '1.0.1', + '1.1.0', + '2.0.0' + ]; + + // Ensure that every pair of versions is prioritized in the order that it + // appears in the list. + for (var i = 0; i < versions.length; i++) { + for (var j = 0; j < versions.length; j++) { + var a = Version.parse(versions[i]); + var b = Version.parse(versions[j]); + expect(Version.prioritize(a, b), equals(i.compareTo(j))); + } + } + }); + + test('antiprioritize()', () { + // A correctly sorted list of versions in order of increasing antipriority. + var versions = [ + '2.0.0-alpha', + '1.0.0-alpha', + '2.0.0', + '1.1.0', + '1.0.1', + '1.0.0+build', + '1.0.0' + ]; + + // Ensure that every pair of versions is prioritized in the order that it + // appears in the list. + for (var i = 0; i < versions.length; i++) { + for (var j = 0; j < versions.length; j++) { + var a = Version.parse(versions[i]); + var b = Version.parse(versions[j]); + expect(Version.antiprioritize(a, b), equals(i.compareTo(j))); + } + } + }); + + group('constructor', () { + test('throws on negative numbers', () { + expect(() => Version(-1, 1, 1), throwsArgumentError); + expect(() => Version(1, -1, 1), throwsArgumentError); + expect(() => Version(1, 1, -1), throwsArgumentError); + }); + }); + + group('comparison', () { + // A correctly sorted list of versions. + var versions = [ + '1.0.0-alpha', + '1.0.0-alpha.1', + '1.0.0-beta.2', + '1.0.0-beta.11', + '1.0.0-rc.1', + '1.0.0-rc.1+build.1', + '1.0.0', + '1.0.0+0.3.7', + '1.3.7+build', + '1.3.7+build.2.b8f12d7', + '1.3.7+build.11.e0f985a', + '2.0.0', + '2.1.0', + '2.2.0', + '2.11.0', + '2.11.1' + ]; + + test('compareTo()', () { + // Ensure that every pair of versions compares in the order that it + // appears in the list. + for (var i = 0; i < versions.length; i++) { + for (var j = 0; j < versions.length; j++) { + var a = Version.parse(versions[i]); + var b = Version.parse(versions[j]); + expect(a.compareTo(b), equals(i.compareTo(j))); + } + } + }); + + test('operators', () { + for (var i = 0; i < versions.length; i++) { + for (var j = 0; j < versions.length; j++) { + var a = Version.parse(versions[i]); + var b = Version.parse(versions[j]); + expect(a < b, equals(i < j)); + expect(a > b, equals(i > j)); + expect(a <= b, equals(i <= j)); + expect(a >= b, equals(i >= j)); + expect(a == b, equals(i == j)); + expect(a != b, equals(i != j)); + } + } + }); + + test('equality', () { + expect(Version.parse('01.2.3'), equals(Version.parse('1.2.3'))); + expect(Version.parse('1.02.3'), equals(Version.parse('1.2.3'))); + expect(Version.parse('1.2.03'), equals(Version.parse('1.2.3'))); + expect(Version.parse('1.2.3-01'), equals(Version.parse('1.2.3-1'))); + expect(Version.parse('1.2.3+01'), equals(Version.parse('1.2.3+1'))); + }); + }); + + test('allows()', () { + expect(v123, allows(v123)); + expect( + v123, + doesNotAllow( + Version.parse('2.2.3'), + Version.parse('1.3.3'), + Version.parse('1.2.4'), + Version.parse('1.2.3-dev'), + Version.parse('1.2.3+build'))); + }); + + test('allowsAll()', () { + expect(v123.allowsAll(v123), isTrue); + expect(v123.allowsAll(v003), isFalse); + expect(v123.allowsAll(VersionRange(min: v114, max: v124)), isFalse); + expect(v123.allowsAll(VersionConstraint.any), isFalse); + expect(v123.allowsAll(VersionConstraint.empty), isTrue); + }); + + test('allowsAny()', () { + expect(v123.allowsAny(v123), isTrue); + expect(v123.allowsAny(v003), isFalse); + expect(v123.allowsAny(VersionRange(min: v114, max: v124)), isTrue); + expect(v123.allowsAny(VersionConstraint.any), isTrue); + expect(v123.allowsAny(VersionConstraint.empty), isFalse); + }); + + test('intersect()', () { + // Intersecting the same version returns the version. + expect(v123.intersect(v123), equals(v123)); + + // Intersecting a different version allows no versions. + expect(v123.intersect(v114).isEmpty, isTrue); + + // Intersecting a range returns the version if the range allows it. + expect(v123.intersect(VersionRange(min: v114, max: v124)), equals(v123)); + + // Intersecting a range allows no versions if the range doesn't allow it. + expect(v114.intersect(VersionRange(min: v123, max: v124)).isEmpty, isTrue); + }); + + group('union()', () { + test('with the same version returns the version', () { + expect(v123.union(v123), equals(v123)); + }); + + test('with a different version returns a version that matches both', () { + var result = v123.union(v080); + expect(result, allows(v123)); + expect(result, allows(v080)); + + // Nothing in between should match. + expect(result, doesNotAllow(v114)); + }); + + test('with a range returns the range if it contains the version', () { + var range = VersionRange(min: v114, max: v124); + expect(v123.union(range), equals(range)); + }); + + test('with a range with the version on the edge, expands the range', () { + expect( + v124.union(VersionRange( + min: v114, max: v124, alwaysIncludeMaxPreRelease: true)), + equals(VersionRange(min: v114, max: v124, includeMax: true))); + expect( + v124.firstPreRelease.union(VersionRange(min: v114, max: v124)), + equals(VersionRange( + min: v114, max: v124.firstPreRelease, includeMax: true))); + expect(v114.union(VersionRange(min: v114, max: v124)), + equals(VersionRange(min: v114, max: v124, includeMin: true))); + }); + + test( + 'with a range allows both the range and the version if the range ' + "doesn't contain the version", () { + var result = v123.union(VersionRange(min: v003, max: v114)); + expect(result, allows(v123)); + expect(result, allows(v010)); + }); + }); + + group('difference()', () { + test('with the same version returns an empty constraint', () { + expect(v123.difference(v123), isEmpty); + }); + + test('with a different version returns the original version', () { + expect(v123.difference(v080), equals(v123)); + }); + + test('returns an empty constraint with a range that contains the version', + () { + expect(v123.difference(VersionRange(min: v114, max: v124)), isEmpty); + }); + + test("returns the version constraint with a range that doesn't contain it", + () { + expect(v123.difference(VersionRange(min: v140, max: v300)), equals(v123)); + }); + }); + + test('isEmpty', () { + expect(v123.isEmpty, isFalse); + }); + + test('nextMajor', () { + expect(v123.nextMajor, equals(v200)); + expect(v114.nextMajor, equals(v200)); + expect(v200.nextMajor, equals(v300)); + + // Ignores pre-release if not on a major version. + expect(Version.parse('1.2.3-dev').nextMajor, equals(v200)); + + // Just removes it if on a major version. + expect(Version.parse('2.0.0-dev').nextMajor, equals(v200)); + + // Strips build suffix. + expect(Version.parse('1.2.3+patch').nextMajor, equals(v200)); + }); + + test('nextMinor', () { + expect(v123.nextMinor, equals(v130)); + expect(v130.nextMinor, equals(v140)); + + // Ignores pre-release if not on a minor version. + expect(Version.parse('1.2.3-dev').nextMinor, equals(v130)); + + // Just removes it if on a minor version. + expect(Version.parse('1.3.0-dev').nextMinor, equals(v130)); + + // Strips build suffix. + expect(Version.parse('1.2.3+patch').nextMinor, equals(v130)); + }); + + test('nextPatch', () { + expect(v123.nextPatch, equals(v124)); + expect(v200.nextPatch, equals(v201)); + + // Just removes pre-release version if present. + expect(Version.parse('1.2.4-dev').nextPatch, equals(v124)); + + // Strips build suffix. + expect(Version.parse('1.2.3+patch').nextPatch, equals(v124)); + }); + + test('nextBreaking', () { + expect(v123.nextBreaking, equals(v200)); + expect(v072.nextBreaking, equals(v080)); + expect(v003.nextBreaking, equals(v010)); + + // Removes pre-release version if present. + expect(Version.parse('1.2.3-dev').nextBreaking, equals(v200)); + + // Strips build suffix. + expect(Version.parse('1.2.3+patch').nextBreaking, equals(v200)); + }); + + test('parse()', () { + expect(Version.parse('0.0.0'), equals(Version(0, 0, 0))); + expect(Version.parse('12.34.56'), equals(Version(12, 34, 56))); + + expect(Version.parse('1.2.3-alpha.1'), + equals(Version(1, 2, 3, pre: 'alpha.1'))); + expect(Version.parse('1.2.3-x.7.z-92'), + equals(Version(1, 2, 3, pre: 'x.7.z-92'))); + + expect(Version.parse('1.2.3+build.1'), + equals(Version(1, 2, 3, build: 'build.1'))); + expect(Version.parse('1.2.3+x.7.z-92'), + equals(Version(1, 2, 3, build: 'x.7.z-92'))); + + expect(Version.parse('1.0.0-rc-1+build-1'), + equals(Version(1, 0, 0, pre: 'rc-1', build: 'build-1'))); + + expect(() => Version.parse('1.0'), throwsFormatException); + expect(() => Version.parse('1a2b3'), throwsFormatException); + expect(() => Version.parse('1.2.3.4'), throwsFormatException); + expect(() => Version.parse('1234'), throwsFormatException); + expect(() => Version.parse('-2.3.4'), throwsFormatException); + expect(() => Version.parse('1.3-pre'), throwsFormatException); + expect(() => Version.parse('1.3+build'), throwsFormatException); + expect(() => Version.parse('1.3+bu?!3ild'), throwsFormatException); + }); + + group('toString()', () { + test('returns the version string', () { + expect(Version(0, 0, 0).toString(), equals('0.0.0')); + expect(Version(12, 34, 56).toString(), equals('12.34.56')); + + expect( + Version(1, 2, 3, pre: 'alpha.1').toString(), equals('1.2.3-alpha.1')); + expect(Version(1, 2, 3, pre: 'x.7.z-92').toString(), + equals('1.2.3-x.7.z-92')); + + expect(Version(1, 2, 3, build: 'build.1').toString(), + equals('1.2.3+build.1')); + expect(Version(1, 2, 3, pre: 'pre', build: 'bui').toString(), + equals('1.2.3-pre+bui')); + }); + + test('preserves leading zeroes', () { + expect(Version.parse('001.02.0003-01.dev+pre.002').toString(), + equals('001.02.0003-01.dev+pre.002')); + }); + }); + + group('canonicalizedVersion', () { + test('returns version string', () { + expect(Version(0, 0, 0).canonicalizedVersion, equals('0.0.0')); + expect(Version(12, 34, 56).canonicalizedVersion, equals('12.34.56')); + + expect(Version(1, 2, 3, pre: 'alpha.1').canonicalizedVersion, + equals('1.2.3-alpha.1')); + expect(Version(1, 2, 3, pre: 'x.7.z-92').canonicalizedVersion, + equals('1.2.3-x.7.z-92')); + + expect(Version(1, 2, 3, build: 'build.1').canonicalizedVersion, + equals('1.2.3+build.1')); + expect(Version(1, 2, 3, pre: 'pre', build: 'bui').canonicalizedVersion, + equals('1.2.3-pre+bui')); + }); + + test('discards leading zeroes', () { + expect(Version.parse('001.02.0003-01.dev+pre.002').canonicalizedVersion, + equals('1.2.3-1.dev+pre.2')); + }); + + test('example from documentation', () { + final v = Version.parse('01.02.03-01.dev+pre.02'); + + assert(v.toString() == '01.02.03-01.dev+pre.02'); + assert(v.canonicalizedVersion == '1.2.3-1.dev+pre.2'); + assert(Version.parse(v.canonicalizedVersion) == v); + }); + }); + + group('primary', () { + test('single', () { + expect( + _primary([ + '1.2.3', + ]).toString(), + '1.2.3', + ); + }); + + test('normal', () { + expect( + _primary([ + '1.2.3', + '1.2.2', + ]).toString(), + '1.2.3', + ); + }); + + test('all prerelease', () { + expect( + _primary([ + '1.2.2-dev.1', + '1.2.2-dev.2', + ]).toString(), + '1.2.2-dev.2', + ); + }); + + test('later prerelease', () { + expect( + _primary([ + '1.2.3', + '1.2.3-dev', + ]).toString(), + '1.2.3', + ); + }); + + test('empty', () { + expect(() => Version.primary([]), throwsStateError); + }); + }); +} + +Version _primary(List input) => + Version.primary(input.map(Version.parse).toList()); diff --git a/pkgs/pub_semver/test/version_union_test.dart b/pkgs/pub_semver/test/version_union_test.dart new file mode 100644 index 000000000..857f10e87 --- /dev/null +++ b/pkgs/pub_semver/test/version_union_test.dart @@ -0,0 +1,482 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + group('factory', () { + test('ignores empty constraints', () { + expect( + VersionConstraint.unionOf([ + VersionConstraint.empty, + VersionConstraint.empty, + v123, + VersionConstraint.empty + ]), + equals(v123)); + + expect( + VersionConstraint.unionOf( + [VersionConstraint.empty, VersionConstraint.empty]), + isEmpty); + }); + + test('returns an empty constraint for an empty list', () { + expect(VersionConstraint.unionOf([]), isEmpty); + }); + + test('any constraints override everything', () { + expect( + VersionConstraint.unionOf([ + v123, + VersionConstraint.any, + v200, + VersionRange(min: v234, max: v250) + ]), + equals(VersionConstraint.any)); + }); + + test('flattens other unions', () { + expect( + VersionConstraint.unionOf([ + v072, + VersionConstraint.unionOf([v123, v124]), + v250 + ]), + equals(VersionConstraint.unionOf([v072, v123, v124, v250]))); + }); + + test('returns a single merged range as-is', () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v080, max: v140), + VersionRange(min: v123, max: v200) + ]), + equals(VersionRange(min: v080, max: v200))); + }); + }); + + group('equality', () { + test("doesn't depend on original order", () { + expect( + VersionConstraint.unionOf([ + v250, + VersionRange(min: v201, max: v234), + v124, + v072, + VersionRange(min: v080, max: v114), + v123 + ]), + equals(VersionConstraint.unionOf([ + v072, + VersionRange(min: v080, max: v114), + v123, + v124, + VersionRange(min: v201, max: v234), + v250 + ]))); + }); + + test('merges overlapping ranges', () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v003, max: v072), + VersionRange(min: v010, max: v080), + VersionRange(min: v114, max: v124), + VersionRange(min: v123, max: v130) + ]), + equals(VersionConstraint.unionOf([ + VersionRange(min: v003, max: v080), + VersionRange(min: v114, max: v130) + ]))); + }); + + test('merges adjacent ranges', () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v003, max: v072, includeMax: true), + VersionRange(min: v072, max: v080), + VersionRange( + min: v114, max: v124, alwaysIncludeMaxPreRelease: true), + VersionRange(min: v124, max: v130, includeMin: true), + VersionRange(min: v130.firstPreRelease, max: v200, includeMin: true) + ]), + equals(VersionConstraint.unionOf([ + VersionRange(min: v003, max: v080), + VersionRange(min: v114, max: v200) + ]))); + }); + + test("doesn't merge not-quite-adjacent ranges", () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v114, max: v124), + VersionRange(min: v124, max: v130, includeMin: true) + ]), + isNot(equals(VersionRange(min: v114, max: v130)))); + + expect( + VersionConstraint.unionOf([ + VersionRange(min: v003, max: v072), + VersionRange(min: v072, max: v080) + ]), + isNot(equals(VersionRange(min: v003, max: v080)))); + }); + + test('merges version numbers into ranges', () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v003, max: v072), + v010, + VersionRange(min: v114, max: v124), + v123 + ]), + equals(VersionConstraint.unionOf([ + VersionRange(min: v003, max: v072), + VersionRange(min: v114, max: v124) + ]))); + }); + + test('merges adjacent version numbers into ranges', () { + expect( + VersionConstraint.unionOf([ + VersionRange( + min: v003, max: v072, alwaysIncludeMaxPreRelease: true), + v072, + v114, + VersionRange(min: v114, max: v124), + v124.firstPreRelease + ]), + equals(VersionConstraint.unionOf([ + VersionRange(min: v003, max: v072, includeMax: true), + VersionRange( + min: v114, + max: v124.firstPreRelease, + includeMin: true, + includeMax: true) + ]))); + }); + + test("doesn't merge not-quite-adjacent version numbers into ranges", () { + expect( + VersionConstraint.unionOf([VersionRange(min: v003, max: v072), v072]), + isNot(equals(VersionRange(min: v003, max: v072, includeMax: true)))); + }); + }); + + test('isEmpty returns false', () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v003, max: v080), + VersionRange(min: v123, max: v130), + ]), + isNot(isEmpty)); + }); + + test('isAny returns false', () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v003, max: v080), + VersionRange(min: v123, max: v130), + ]).isAny, + isFalse); + }); + + test('allows() allows anything the components allow', () { + var union = VersionConstraint.unionOf([ + VersionRange(min: v003, max: v080), + VersionRange(min: v123, max: v130), + v200 + ]); + + expect(union, allows(v010)); + expect(union, doesNotAllow(v080)); + expect(union, allows(v124)); + expect(union, doesNotAllow(v140)); + expect(union, allows(v200)); + }); + + group('allowsAll()', () { + test('for a version, returns true if any component allows the version', () { + var union = VersionConstraint.unionOf([ + VersionRange(min: v003, max: v080), + VersionRange(min: v123, max: v130), + v200 + ]); + + expect(union.allowsAll(v010), isTrue); + expect(union.allowsAll(v080), isFalse); + expect(union.allowsAll(v124), isTrue); + expect(union.allowsAll(v140), isFalse); + expect(union.allowsAll(v200), isTrue); + }); + + test( + 'for a version range, returns true if any component allows the whole ' + 'range', () { + var union = VersionConstraint.unionOf([ + VersionRange(min: v003, max: v080), + VersionRange(min: v123, max: v130) + ]); + + expect(union.allowsAll(VersionRange(min: v003, max: v080)), isTrue); + expect(union.allowsAll(VersionRange(min: v010, max: v072)), isTrue); + expect(union.allowsAll(VersionRange(min: v010, max: v124)), isFalse); + }); + + group('for a union,', () { + var union = VersionConstraint.unionOf([ + VersionRange(min: v003, max: v080), + VersionRange(min: v123, max: v130) + ]); + + test('returns true if every constraint matches a different constraint', + () { + expect( + union.allowsAll(VersionConstraint.unionOf([ + VersionRange(min: v010, max: v072), + VersionRange(min: v124, max: v130) + ])), + isTrue); + }); + + test('returns true if every constraint matches the same constraint', () { + expect( + union.allowsAll(VersionConstraint.unionOf([ + VersionRange(min: v003, max: v010), + VersionRange(min: v072, max: v080) + ])), + isTrue); + }); + + test("returns false if there's an unmatched constraint", () { + expect( + union.allowsAll(VersionConstraint.unionOf([ + VersionRange(min: v010, max: v072), + VersionRange(min: v124, max: v130), + VersionRange(min: v140, max: v200) + ])), + isFalse); + }); + + test("returns false if a constraint isn't fully matched", () { + expect( + union.allowsAll(VersionConstraint.unionOf([ + VersionRange(min: v010, max: v114), + VersionRange(min: v124, max: v130) + ])), + isFalse); + }); + }); + }); + + group('allowsAny()', () { + test('for a version, returns true if any component allows the version', () { + var union = VersionConstraint.unionOf([ + VersionRange(min: v003, max: v080), + VersionRange(min: v123, max: v130), + v200 + ]); + + expect(union.allowsAny(v010), isTrue); + expect(union.allowsAny(v080), isFalse); + expect(union.allowsAny(v124), isTrue); + expect(union.allowsAny(v140), isFalse); + expect(union.allowsAny(v200), isTrue); + }); + + test( + 'for a version range, returns true if any component allows part of ' + 'the range', () { + var union = + VersionConstraint.unionOf([VersionRange(min: v003, max: v080), v123]); + + expect(union.allowsAny(VersionRange(min: v010, max: v114)), isTrue); + expect(union.allowsAny(VersionRange(min: v114, max: v124)), isTrue); + expect(union.allowsAny(VersionRange(min: v124, max: v130)), isFalse); + }); + + group('for a union,', () { + var union = VersionConstraint.unionOf([ + VersionRange(min: v010, max: v080), + VersionRange(min: v123, max: v130) + ]); + + test('returns true if any constraint matches', () { + expect( + union.allowsAny(VersionConstraint.unionOf( + [v072, VersionRange(min: v200, max: v300)])), + isTrue); + + expect( + union.allowsAny(VersionConstraint.unionOf( + [v003, VersionRange(min: v124, max: v300)])), + isTrue); + }); + + test('returns false if no constraint matches', () { + expect( + union.allowsAny(VersionConstraint.unionOf([ + v003, + VersionRange(min: v130, max: v140), + VersionRange(min: v140, max: v200) + ])), + isFalse); + }); + }); + }); + + group('intersect()', () { + test('with an overlapping version, returns that version', () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v010, max: v080), + VersionRange(min: v123, max: v140) + ]).intersect(v072), + equals(v072)); + }); + + test('with a non-overlapping version, returns an empty constraint', () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v010, max: v080), + VersionRange(min: v123, max: v140) + ]).intersect(v300), + isEmpty); + }); + + test('with an overlapping range, returns that range', () { + var range = VersionRange(min: v072, max: v080); + expect( + VersionConstraint.unionOf([ + VersionRange(min: v010, max: v080), + VersionRange(min: v123, max: v140) + ]).intersect(range), + equals(range)); + }); + + test('with a non-overlapping range, returns an empty constraint', () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v010, max: v080), + VersionRange(min: v123, max: v140) + ]).intersect(VersionRange(min: v080, max: v123)), + isEmpty); + }); + + test('with a parially-overlapping range, returns the overlapping parts', + () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v010, max: v080), + VersionRange(min: v123, max: v140) + ]).intersect(VersionRange(min: v072, max: v130)), + equals(VersionConstraint.unionOf([ + VersionRange(min: v072, max: v080), + VersionRange(min: v123, max: v130) + ]))); + }); + + group('for a union,', () { + var union = VersionConstraint.unionOf([ + VersionRange(min: v003, max: v080), + VersionRange(min: v123, max: v130) + ]); + + test('returns the overlapping parts', () { + expect( + union.intersect(VersionConstraint.unionOf([ + v010, + VersionRange(min: v072, max: v124), + VersionRange(min: v124, max: v130) + ])), + equals(VersionConstraint.unionOf([ + v010, + VersionRange(min: v072, max: v080), + VersionRange(min: v123, max: v124), + VersionRange(min: v124, max: v130) + ]))); + }); + + test("drops parts that don't match", () { + expect( + union.intersect(VersionConstraint.unionOf([ + v003, + VersionRange(min: v072, max: v080), + VersionRange(min: v080, max: v123) + ])), + equals(VersionRange(min: v072, max: v080))); + }); + }); + }); + + group('difference()', () { + test("ignores ranges that don't intersect", () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v072, max: v080), + VersionRange(min: v123, max: v130) + ]).difference(VersionConstraint.unionOf([ + VersionRange(min: v003, max: v010), + VersionRange(min: v080, max: v123), + VersionRange(min: v140) + ])), + equals(VersionConstraint.unionOf([ + VersionRange(min: v072, max: v080), + VersionRange(min: v123, max: v130) + ]))); + }); + + test('removes overlapping portions', () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v010, max: v080), + VersionRange(min: v123, max: v130) + ]).difference(VersionConstraint.unionOf( + [VersionRange(min: v003, max: v072), VersionRange(min: v124)])), + equals(VersionConstraint.unionOf([ + VersionRange( + min: v072.firstPreRelease, max: v080, includeMin: true), + VersionRange(min: v123, max: v124, includeMax: true) + ]))); + }); + + test('removes multiple portions from the same range', () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v010, max: v114), + VersionRange(min: v130, max: v200) + ]).difference(VersionConstraint.unionOf([v072, v080])), + equals(VersionConstraint.unionOf([ + VersionRange( + min: v010, max: v072, alwaysIncludeMaxPreRelease: true), + VersionRange( + min: v072, max: v080, alwaysIncludeMaxPreRelease: true), + VersionRange(min: v080, max: v114), + VersionRange(min: v130, max: v200) + ]))); + }); + + test('removes the same range from multiple ranges', () { + expect( + VersionConstraint.unionOf([ + VersionRange(min: v010, max: v072), + VersionRange(min: v080, max: v123), + VersionRange(min: v124, max: v130), + VersionRange(min: v200, max: v234), + VersionRange(min: v250, max: v300) + ]).difference(VersionRange(min: v114, max: v201)), + equals(VersionConstraint.unionOf([ + VersionRange(min: v010, max: v072), + VersionRange(min: v080, max: v114, includeMax: true), + VersionRange( + min: v201.firstPreRelease, max: v234, includeMin: true), + VersionRange(min: v250, max: v300) + ]))); + }); + }); +} diff --git a/pkgs/pubspec_parse/.gitignore b/pkgs/pubspec_parse/.gitignore new file mode 100644 index 000000000..ec8eae3f1 --- /dev/null +++ b/pkgs/pubspec_parse/.gitignore @@ -0,0 +1,4 @@ +# Don’t commit the following directories created by pub. +.dart_tool/ +.packages +pubspec.lock diff --git a/pkgs/pubspec_parse/CHANGELOG.md b/pkgs/pubspec_parse/CHANGELOG.md new file mode 100644 index 000000000..a5f0f1a30 --- /dev/null +++ b/pkgs/pubspec_parse/CHANGELOG.md @@ -0,0 +1,104 @@ +## 1.4.0 + +- Require Dart 3.2 +- Seal the `Dependency` class. +- Set `Pubspec.environment` to non-nullable. +- Remove deprecated package_api_docs rule +- Move to `dart-lang/tools` monorepo. + +## 1.3.0 + +- Require Dart 3.0 +- Added support for `ignored_advisories` field. +- Added structural equality for `Dependency` subclasses and `HostedDetails`. + +## 1.2.3 + +- Added topics to `pubspec.yaml`. + +## 1.2.2 + +- Require Dart SDK >= 2.18.0 +- Required `json_annotation: ^4.8.0` +- Added support for `topics` field. + +## 1.2.1 + +- Added support for `funding` field. + +## 1.2.0 + +- Added support for `screenshots` field. +- Update `HostedDetails` to reflect how `hosted` dependencies are parsed in + Dart 2.15: + - Add `HostedDetails.declaredName` as the (optional) `name` property in a + `hosted` block. + - `HostedDetails.name` now falls back to the name of the dependency if no + name is declared in the block. +- Require Dart SDK >= 2.14.0 + +## 1.1.0 + +- Export `HostedDetails` publicly. + +## 1.0.0 + +- Migrate to null-safety. +- Pubspec: `author` and `authors` are both now deprecated. + See https://dart.dev/tools/pub/pubspec#authorauthors + +## 0.1.8 + +- Allow the latest `package:pub_semver`. + +## 0.1.7 + +- Allow `package:yaml` `v3.x`. + +## 0.1.6 + +- Update SDK requirement to `>=2.7.0 <3.0.0`. +- Allow `package:json_annotation` `v4.x`. + +## 0.1.5 + +- Update SDK requirement to `>=2.2.0 <3.0.0`. +- Support the latest `package:json_annotation`. + +## 0.1.4 + +- Added `lenient` named argument to `Pubspec.fromJson` to ignore format and type errors. + +## 0.1.3 + +- Added support for `flutter`, `issue_tracker`, `publish_to`, and `repository` + fields. + +## 0.1.2+3 + +- Support the latest version of `package:json_annotation`. + +## 0.1.2+2 + +- Support `package:json_annotation` v1. + +## 0.1.2+1 + +- Support the Dart 2 stable release. + +## 0.1.2 + +- Allow superfluous `version` keys with `git` and `path` dependencies. +- Improve errors when unsupported keys are provided in dependencies. +- Provide better errors with invalid `sdk` dependency values. +- Support "scp-like syntax" for Git SSH URIs in the form + `[user@]host.xz:path/to/repo.git/`. + +## 0.1.1 + +- Fixed name collision with error type in latest `package:json_annotation`. +- Improved parsing of hosted dependencies and environment constraints. + +## 0.1.0 + +- Initial release. diff --git a/pkgs/pubspec_parse/LICENSE b/pkgs/pubspec_parse/LICENSE new file mode 100644 index 000000000..4d1ad40a1 --- /dev/null +++ b/pkgs/pubspec_parse/LICENSE @@ -0,0 +1,27 @@ +Copyright 2018, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/pubspec_parse/README.md b/pkgs/pubspec_parse/README.md new file mode 100644 index 000000000..1d04aa486 --- /dev/null +++ b/pkgs/pubspec_parse/README.md @@ -0,0 +1,12 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/pubspec_parse.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/pubspec_parse.yaml) +[![pub package](https://img.shields.io/pub/v/pubspec_parse.svg)](https://pub.dev/packages/pubspec_parse) +[![package publisher](https://img.shields.io/pub/publisher/pubspec_parse.svg)](https://pub.dev/packages/pubspec_parse/publisher) + +## What's this? + +Supports parsing `pubspec.yaml` files with robust error reporting and support +for most of the documented features. + +## More information + +Read more about the [pubspec format](https://dart.dev/tools/pub/pubspec). diff --git a/pkgs/pubspec_parse/analysis_options.yaml b/pkgs/pubspec_parse/analysis_options.yaml new file mode 100644 index 000000000..93eeebff2 --- /dev/null +++ b/pkgs/pubspec_parse/analysis_options.yaml @@ -0,0 +1,30 @@ +# https://dart.dev/guides/language/analysis-options +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + +linter: + rules: + - avoid_bool_literals_in_conditional_expressions + - avoid_classes_with_only_static_members + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_returning_this + - avoid_unused_constructor_parameters + - avoid_void_async + - cancel_subscriptions + - cascade_invocations + - join_return_with_assignment + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_runtimeType_toString + - prefer_const_declarations + - prefer_expression_function_bodies + - prefer_final_locals + - require_trailing_commas + - unnecessary_await_in_return + - use_string_buffers diff --git a/pkgs/pubspec_parse/build.yaml b/pkgs/pubspec_parse/build.yaml new file mode 100644 index 000000000..2003bc29e --- /dev/null +++ b/pkgs/pubspec_parse/build.yaml @@ -0,0 +1,25 @@ +# Read about `build.yaml` at https://pub.dev/packages/build_config +# To update generated code, run `pub run build_runner build` +targets: + $default: + builders: + json_serializable: + generate_for: + - lib/src/pubspec.dart + - lib/src/dependency.dart + options: + any_map: true + checked: true + create_to_json: false + field_rename: snake + + # The end-user of a builder which applies "source_gen|combining_builder" + # may configure the builder to ignore specific lints for their project + source_gen|combining_builder: + options: + ignore_for_file: + - deprecated_member_use_from_same_package + - lines_longer_than_80_chars + - require_trailing_commas + # https://github.com/google/json_serializable.dart/issues/945 + - unnecessary_cast diff --git a/pkgs/pubspec_parse/dart_test.yaml b/pkgs/pubspec_parse/dart_test.yaml new file mode 100644 index 000000000..1d7ac69cc --- /dev/null +++ b/pkgs/pubspec_parse/dart_test.yaml @@ -0,0 +1,3 @@ +tags: + presubmit-only: + skip: "Should only be run during presubmit" diff --git a/pkgs/pubspec_parse/lib/pubspec_parse.dart b/pkgs/pubspec_parse/lib/pubspec_parse.dart new file mode 100644 index 000000000..b5c12e414 --- /dev/null +++ b/pkgs/pubspec_parse/lib/pubspec_parse.dart @@ -0,0 +1,14 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'src/dependency.dart' + show + Dependency, + GitDependency, + HostedDependency, + HostedDetails, + PathDependency, + SdkDependency; +export 'src/pubspec.dart' show Pubspec; +export 'src/screenshot.dart' show Screenshot; diff --git a/pkgs/pubspec_parse/lib/src/dependency.dart b/pkgs/pubspec_parse/lib/src/dependency.dart new file mode 100644 index 000000000..24c65eac1 --- /dev/null +++ b/pkgs/pubspec_parse/lib/src/dependency.dart @@ -0,0 +1,277 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:collection/collection.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:yaml/yaml.dart'; + +part 'dependency.g.dart'; + +Map parseDeps(Map? source) => + source?.map((k, v) { + final key = k as String; + Dependency? value; + try { + value = _fromJson(v, k); + } on CheckedFromJsonException catch (e) { + if (e.map is! YamlMap) { + // This is likely a "synthetic" map created from a String value + // Use `source` to throw this exception with an actual YamlMap and + // extract the associated error information. + throw CheckedFromJsonException(source, key, e.className!, e.message); + } + rethrow; + } + + if (value == null) { + throw CheckedFromJsonException( + source, + key, + 'Pubspec', + 'Not a valid dependency value.', + ); + } + return MapEntry(key, value); + }) ?? + {}; + +const _sourceKeys = ['sdk', 'git', 'path', 'hosted']; + +/// Returns `null` if the data could not be parsed. +Dependency? _fromJson(Object? data, String name) { + if (data is String || data == null) { + return _$HostedDependencyFromJson({'version': data}); + } + + if (data is Map) { + final matchedKeys = + data.keys.cast().where((key) => key != 'version').toList(); + + if (data.isEmpty || (matchedKeys.isEmpty && data.containsKey('version'))) { + return _$HostedDependencyFromJson(data); + } else { + final firstUnrecognizedKey = + matchedKeys.firstWhereOrNull((k) => !_sourceKeys.contains(k)); + + return $checkedNew('Dependency', data, () { + if (firstUnrecognizedKey != null) { + throw UnrecognizedKeysException( + [firstUnrecognizedKey], + data, + _sourceKeys, + ); + } + if (matchedKeys.length > 1) { + throw CheckedFromJsonException( + data, + matchedKeys[1], + 'Dependency', + 'A dependency may only have one source.', + ); + } + + final key = matchedKeys.single; + + return switch (key) { + 'git' => GitDependency.fromData(data[key]), + 'path' => PathDependency.fromData(data[key]), + 'sdk' => _$SdkDependencyFromJson(data), + 'hosted' => _$HostedDependencyFromJson(data) + ..hosted?._nameOfPackage = name, + _ => throw StateError('There is a bug in pubspec_parse.'), + }; + }); + } + } + + // Not a String or a Map – return null so parent logic can throw proper error + return null; +} + +sealed class Dependency {} + +@JsonSerializable() +class SdkDependency extends Dependency { + final String sdk; + @JsonKey(fromJson: _constraintFromString) + final VersionConstraint version; + + SdkDependency(this.sdk, {VersionConstraint? version}) + : version = version ?? VersionConstraint.any; + + @override + bool operator ==(Object other) => + other is SdkDependency && other.sdk == sdk && other.version == version; + + @override + int get hashCode => Object.hash(sdk, version); + + @override + String toString() => 'SdkDependency: $sdk'; +} + +@JsonSerializable() +class GitDependency extends Dependency { + @JsonKey(fromJson: parseGitUri) + final Uri url; + final String? ref; + final String? path; + + GitDependency(this.url, {this.ref, this.path}); + + factory GitDependency.fromData(Object? data) { + if (data is String) { + data = {'url': data}; + } + + if (data is Map) { + return _$GitDependencyFromJson(data); + } + + throw ArgumentError.value(data, 'git', 'Must be a String or a Map.'); + } + + @override + bool operator ==(Object other) => + other is GitDependency && + other.url == url && + other.ref == ref && + other.path == path; + + @override + int get hashCode => Object.hash(url, ref, path); + + @override + String toString() => 'GitDependency: url@$url'; +} + +Uri? parseGitUriOrNull(String? value) => + value == null ? null : parseGitUri(value); + +Uri parseGitUri(String value) => _tryParseScpUri(value) ?? Uri.parse(value); + +/// Supports URIs like `[user@]host.xz:path/to/repo.git/` +/// See https://git-scm.com/docs/git-clone#_git_urls_a_id_urls_a +Uri? _tryParseScpUri(String value) { + final colonIndex = value.indexOf(':'); + + if (colonIndex < 0) { + return null; + } else if (colonIndex == value.indexOf('://')) { + // If the first colon is part of a scheme, it's not an scp-like URI + return null; + } + final slashIndex = value.indexOf('/'); + + if (slashIndex >= 0 && slashIndex < colonIndex) { + // Per docs: This syntax is only recognized if there are no slashes before + // the first colon. This helps differentiate a local path that contains a + // colon. For example the local path foo:bar could be specified as an + // absolute path or ./foo:bar to avoid being misinterpreted as an ssh url. + return null; + } + + final atIndex = value.indexOf('@'); + if (colonIndex > atIndex) { + final user = atIndex >= 0 ? value.substring(0, atIndex) : null; + final host = value.substring(atIndex + 1, colonIndex); + final path = value.substring(colonIndex + 1); + return Uri(scheme: 'ssh', userInfo: user, host: host, path: path); + } + return null; +} + +class PathDependency extends Dependency { + final String path; + + PathDependency(this.path); + + factory PathDependency.fromData(Object? data) { + if (data is String) { + return PathDependency(data); + } + throw ArgumentError.value(data, 'path', 'Must be a String.'); + } + + @override + bool operator ==(Object other) => + other is PathDependency && other.path == path; + + @override + int get hashCode => path.hashCode; + + @override + String toString() => 'PathDependency: path@$path'; +} + +@JsonSerializable(disallowUnrecognizedKeys: true) +class HostedDependency extends Dependency { + @JsonKey(fromJson: _constraintFromString) + final VersionConstraint version; + + @JsonKey(disallowNullValue: true) + final HostedDetails? hosted; + + HostedDependency({VersionConstraint? version, this.hosted}) + : version = version ?? VersionConstraint.any; + + @override + bool operator ==(Object other) => + other is HostedDependency && + other.version == version && + other.hosted == hosted; + + @override + int get hashCode => Object.hash(version, hosted); + + @override + String toString() => 'HostedDependency: $version'; +} + +@JsonSerializable(disallowUnrecognizedKeys: true) +class HostedDetails { + /// The name of the target dependency as declared in a `hosted` block. + /// + /// This may be null if no explicit name is present, for instance because the + /// hosted dependency was declared as a string (`hosted: pub.example.org`). + @JsonKey(name: 'name') + final String? declaredName; + + @JsonKey(fromJson: parseGitUriOrNull, disallowNullValue: true) + final Uri? url; + + @JsonKey(includeFromJson: false, includeToJson: false) + String? _nameOfPackage; + + /// The name of this package on the package repository. + /// + /// If this hosted block has a [declaredName], that one will be used. + /// Otherwise, the name will be inferred from the surrounding package name. + String get name => declaredName ?? _nameOfPackage!; + + HostedDetails(this.declaredName, this.url); + + factory HostedDetails.fromJson(Object data) { + if (data is String) { + data = {'url': data}; + } + + if (data is Map) { + return _$HostedDetailsFromJson(data); + } + + throw ArgumentError.value(data, 'hosted', 'Must be a Map or String.'); + } + + @override + bool operator ==(Object other) => + other is HostedDetails && other.name == name && other.url == url; + + @override + int get hashCode => Object.hash(name, url); +} + +VersionConstraint _constraintFromString(String? input) => + input == null ? VersionConstraint.any : VersionConstraint.parse(input); diff --git a/pkgs/pubspec_parse/lib/src/dependency.g.dart b/pkgs/pubspec_parse/lib/src/dependency.g.dart new file mode 100644 index 000000000..1a504f1fd --- /dev/null +++ b/pkgs/pubspec_parse/lib/src/dependency.g.dart @@ -0,0 +1,72 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: deprecated_member_use_from_same_package, lines_longer_than_80_chars, require_trailing_commas, unnecessary_cast + +part of 'dependency.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SdkDependency _$SdkDependencyFromJson(Map json) => $checkedCreate( + 'SdkDependency', + json, + ($checkedConvert) { + final val = SdkDependency( + $checkedConvert('sdk', (v) => v as String), + version: $checkedConvert( + 'version', (v) => _constraintFromString(v as String?)), + ); + return val; + }, + ); + +GitDependency _$GitDependencyFromJson(Map json) => $checkedCreate( + 'GitDependency', + json, + ($checkedConvert) { + final val = GitDependency( + $checkedConvert('url', (v) => parseGitUri(v as String)), + ref: $checkedConvert('ref', (v) => v as String?), + path: $checkedConvert('path', (v) => v as String?), + ); + return val; + }, + ); + +HostedDependency _$HostedDependencyFromJson(Map json) => $checkedCreate( + 'HostedDependency', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const ['version', 'hosted'], + disallowNullValues: const ['hosted'], + ); + final val = HostedDependency( + version: $checkedConvert( + 'version', (v) => _constraintFromString(v as String?)), + hosted: $checkedConvert('hosted', + (v) => v == null ? null : HostedDetails.fromJson(v as Object)), + ); + return val; + }, + ); + +HostedDetails _$HostedDetailsFromJson(Map json) => $checkedCreate( + 'HostedDetails', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const ['name', 'url'], + disallowNullValues: const ['url'], + ); + final val = HostedDetails( + $checkedConvert('name', (v) => v as String?), + $checkedConvert('url', (v) => parseGitUriOrNull(v as String?)), + ); + return val; + }, + fieldKeyMap: const {'declaredName': 'name'}, + ); diff --git a/pkgs/pubspec_parse/lib/src/pubspec.dart b/pkgs/pubspec_parse/lib/src/pubspec.dart new file mode 100644 index 000000000..1317a2309 --- /dev/null +++ b/pkgs/pubspec_parse/lib/src/pubspec.dart @@ -0,0 +1,226 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:checked_yaml/checked_yaml.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:pub_semver/pub_semver.dart'; + +import 'dependency.dart'; +import 'screenshot.dart'; + +part 'pubspec.g.dart'; + +@JsonSerializable() +class Pubspec { + // TODO: executables + + final String name; + + @JsonKey(fromJson: _versionFromString) + final Version? version; + + final String? description; + + /// This should be a URL pointing to the website for the package. + final String? homepage; + + /// Specifies where to publish this package. + /// + /// Accepted values: `null`, `'none'` or an `http` or `https` URL. + /// + /// [More information](https://dart.dev/tools/pub/pubspec#publish_to). + final String? publishTo; + + /// Optional field to specify the source code repository of the package. + /// Useful when a package has both a home page and a repository. + final Uri? repository; + + /// Optional field to a web page where developers can report new issues or + /// view existing ones. + final Uri? issueTracker; + + /// Optional field to list the URLs where the package authors accept + /// support or funding. + final List? funding; + + /// Optional field to list the topics that this packages belongs to. + final List? topics; + + /// Optional field to list advisories to be ignored by the client. + final List? ignoredAdvisories; + + /// Optional field for specifying included screenshot files. + @JsonKey(fromJson: parseScreenshots) + final List? screenshots; + + /// If there is exactly 1 value in [authors], returns it. + /// + /// If there are 0 or more than 1, returns `null`. + @Deprecated( + 'See https://dart.dev/tools/pub/pubspec#authorauthors', + ) + String? get author { + if (authors.length == 1) { + return authors.single; + } + return null; + } + + @Deprecated( + 'See https://dart.dev/tools/pub/pubspec#authorauthors', + ) + final List authors; + final String? documentation; + + @JsonKey(fromJson: _environmentMap) + final Map environment; + + @JsonKey(fromJson: parseDeps) + final Map dependencies; + + @JsonKey(fromJson: parseDeps) + final Map devDependencies; + + @JsonKey(fromJson: parseDeps) + final Map dependencyOverrides; + + /// Optional configuration specific to [Flutter](https://flutter.io/) + /// packages. + /// + /// May include + /// [assets](https://flutter.io/docs/development/ui/assets-and-images) + /// and other settings. + final Map? flutter; + + /// If [author] and [authors] are both provided, their values are combined + /// with duplicates eliminated. + Pubspec( + this.name, { + this.version, + this.publishTo, + @Deprecated( + 'See https://dart.dev/tools/pub/pubspec#authorauthors', + ) + String? author, + @Deprecated( + 'See https://dart.dev/tools/pub/pubspec#authorauthors', + ) + List? authors, + Map? environment, + this.homepage, + this.repository, + this.issueTracker, + this.funding, + this.topics, + this.ignoredAdvisories, + this.screenshots, + this.documentation, + this.description, + Map? dependencies, + Map? devDependencies, + Map? dependencyOverrides, + this.flutter, + }) : + // ignore: deprecated_member_use_from_same_package + authors = _normalizeAuthors(author, authors), + environment = environment ?? const {}, + dependencies = dependencies ?? const {}, + devDependencies = devDependencies ?? const {}, + dependencyOverrides = dependencyOverrides ?? const {} { + if (name.isEmpty) { + throw ArgumentError.value(name, 'name', '"name" cannot be empty.'); + } + + if (publishTo != null && publishTo != 'none') { + try { + final targetUri = Uri.parse(publishTo!); + if (!(targetUri.isScheme('http') || targetUri.isScheme('https'))) { + throw const FormatException('Must be an http or https URL.'); + } + } on FormatException catch (e) { + throw ArgumentError.value(publishTo, 'publishTo', e.message); + } + } + } + + factory Pubspec.fromJson(Map json, {bool lenient = false}) { + if (lenient) { + while (json.isNotEmpty) { + // Attempting to remove top-level properties that cause parsing errors. + try { + return _$PubspecFromJson(json); + } on CheckedFromJsonException catch (e) { + if (e.map == json && json.containsKey(e.key)) { + json = Map.from(json)..remove(e.key); + continue; + } + rethrow; + } + } + } + + return _$PubspecFromJson(json); + } + + /// Parses source [yaml] into [Pubspec]. + /// + /// When [lenient] is set, top-level property-parsing or type cast errors are + /// ignored and `null` values are returned. + factory Pubspec.parse(String yaml, {Uri? sourceUrl, bool lenient = false}) => + checkedYamlDecode( + yaml, + (map) => Pubspec.fromJson(map!, lenient: lenient), + sourceUrl: sourceUrl, + ); + + static List _normalizeAuthors(String? author, List? authors) { + final value = { + if (author != null) author, + ...?authors, + }; + return value.toList(); + } +} + +Version? _versionFromString(String? input) => + input == null ? null : Version.parse(input); + +Map _environmentMap(Map? source) => + source?.map((k, value) { + final key = k as String; + if (key == 'dart') { + // github.com/dart-lang/pub/blob/d84173eeb03c3/lib/src/pubspec.dart#L342 + // 'dart' is not allowed as a key! + throw CheckedFromJsonException( + source, + 'dart', + 'VersionConstraint', + 'Use "sdk" to for Dart SDK constraints.', + badKey: true, + ); + } + + VersionConstraint? constraint; + if (value == null) { + constraint = null; + } else if (value is String) { + try { + constraint = VersionConstraint.parse(value); + } on FormatException catch (e) { + throw CheckedFromJsonException(source, key, 'Pubspec', e.message); + } + + return MapEntry(key, constraint); + } else { + throw CheckedFromJsonException( + source, + key, + 'VersionConstraint', + '`$value` is not a String.', + ); + } + + return MapEntry(key, constraint); + }) ?? + {}; diff --git a/pkgs/pubspec_parse/lib/src/pubspec.g.dart b/pkgs/pubspec_parse/lib/src/pubspec.g.dart new file mode 100644 index 000000000..fc285718d --- /dev/null +++ b/pkgs/pubspec_parse/lib/src/pubspec.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: deprecated_member_use_from_same_package, lines_longer_than_80_chars, require_trailing_commas, unnecessary_cast + +part of 'pubspec.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Pubspec _$PubspecFromJson(Map json) => $checkedCreate( + 'Pubspec', + json, + ($checkedConvert) { + final val = Pubspec( + $checkedConvert('name', (v) => v as String), + version: $checkedConvert( + 'version', (v) => _versionFromString(v as String?)), + publishTo: $checkedConvert('publish_to', (v) => v as String?), + author: $checkedConvert('author', (v) => v as String?), + authors: $checkedConvert('authors', + (v) => (v as List?)?.map((e) => e as String).toList()), + environment: + $checkedConvert('environment', (v) => _environmentMap(v as Map?)), + homepage: $checkedConvert('homepage', (v) => v as String?), + repository: $checkedConvert( + 'repository', (v) => v == null ? null : Uri.parse(v as String)), + issueTracker: $checkedConvert('issue_tracker', + (v) => v == null ? null : Uri.parse(v as String)), + funding: $checkedConvert( + 'funding', + (v) => (v as List?) + ?.map((e) => Uri.parse(e as String)) + .toList()), + topics: $checkedConvert('topics', + (v) => (v as List?)?.map((e) => e as String).toList()), + ignoredAdvisories: $checkedConvert('ignored_advisories', + (v) => (v as List?)?.map((e) => e as String).toList()), + screenshots: $checkedConvert( + 'screenshots', (v) => parseScreenshots(v as List?)), + documentation: $checkedConvert('documentation', (v) => v as String?), + description: $checkedConvert('description', (v) => v as String?), + dependencies: + $checkedConvert('dependencies', (v) => parseDeps(v as Map?)), + devDependencies: + $checkedConvert('dev_dependencies', (v) => parseDeps(v as Map?)), + dependencyOverrides: $checkedConvert( + 'dependency_overrides', (v) => parseDeps(v as Map?)), + flutter: $checkedConvert( + 'flutter', + (v) => (v as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + ); + return val; + }, + fieldKeyMap: const { + 'publishTo': 'publish_to', + 'issueTracker': 'issue_tracker', + 'ignoredAdvisories': 'ignored_advisories', + 'devDependencies': 'dev_dependencies', + 'dependencyOverrides': 'dependency_overrides' + }, + ); diff --git a/pkgs/pubspec_parse/lib/src/screenshot.dart b/pkgs/pubspec_parse/lib/src/screenshot.dart new file mode 100644 index 000000000..f5f0be2ea --- /dev/null +++ b/pkgs/pubspec_parse/lib/src/screenshot.dart @@ -0,0 +1,65 @@ +// Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:json_annotation/json_annotation.dart'; + +@JsonSerializable() +class Screenshot { + final String description; + final String path; + + Screenshot(this.description, this.path); +} + +List parseScreenshots(List? input) { + final res = []; + if (input == null) { + return res; + } + + for (final e in input) { + if (e is! Map) continue; + + final description = e['description']; + if (description == null) { + throw CheckedFromJsonException( + e, + 'description', + 'Screenshot', + 'Missing required key `description`', + ); + } + + if (description is! String) { + throw CheckedFromJsonException( + e, + 'description', + 'Screenshot', + '`$description` is not a String', + ); + } + + final path = e['path']; + if (path == null) { + throw CheckedFromJsonException( + e, + 'path', + 'Screenshot', + 'Missing required key `path`', + ); + } + + if (path is! String) { + throw CheckedFromJsonException( + e, + 'path', + 'Screenshot', + '`$path` is not a String', + ); + } + + res.add(Screenshot(description, path)); + } + return res; +} diff --git a/pkgs/pubspec_parse/pubspec.yaml b/pkgs/pubspec_parse/pubspec.yaml new file mode 100644 index 000000000..ad0c55e67 --- /dev/null +++ b/pkgs/pubspec_parse/pubspec.yaml @@ -0,0 +1,32 @@ +name: pubspec_parse +version: 1.4.0 +description: >- + Simple package for parsing pubspec.yaml files with a type-safe API and rich + error reporting. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/pubspec_parse + +topics: +- dart-pub + +environment: + sdk: ^3.2.0 + +dependencies: + checked_yaml: ^2.0.1 + collection: ^1.15.0 + json_annotation: ^4.8.0 + pub_semver: ^2.0.0 + yaml: ^3.0.0 + +dev_dependencies: + build_runner: ^2.2.1 + build_verify: ^3.0.0 + dart_flutter_team_lints: ^3.0.0 + json_serializable: ^6.6.0 + path: ^1.8.0 + # Needed because we are configuring `combining_builder` + source_gen: ^1.2.3 + stack_trace: ^1.10.0 + test: ^1.21.6 + test_descriptor: ^2.0.0 + test_process: ^2.0.0 diff --git a/pkgs/pubspec_parse/test/dependency_test.dart b/pkgs/pubspec_parse/test/dependency_test.dart new file mode 100644 index 000000000..f1e4f5776 --- /dev/null +++ b/pkgs/pubspec_parse/test/dependency_test.dart @@ -0,0 +1,446 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:io'; + +import 'package:pub_semver/pub_semver.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +void main() { + group('hosted', _hostedDependency); + group('git', _gitDependency); + group('sdk', _sdkDependency); + group('path', _pathDependency); + + group('errors', () { + test('List', () { + _expectThrows( + [], + r''' +line 4, column 10: Unsupported value for "dep". Not a valid dependency value. + ╷ +4 │ "dep": [] + │ ^^ + ╵''', + ); + }); + + test('int', () { + _expectThrows( + 42, + r''' +line 4, column 10: Unsupported value for "dep". Not a valid dependency value. + ╷ +4 │ "dep": 42 + │ ┌──────────^ +5 │ │ } + │ └─^ + ╵''', + ); + }); + + test('map with too many keys', () { + _expectThrows( + {'path': 'a', 'git': 'b'}, + r''' +line 6, column 11: Unsupported value for "git". A dependency may only have one source. + ╷ +6 │ "git": "b" + │ ^^^ + ╵''', + ); + }); + + test('map with unsupported keys', () { + _expectThrows( + {'bob': 'a', 'jones': 'b'}, + r''' +line 5, column 4: Unrecognized keys: [bob]; supported keys: [sdk, git, path, hosted] + ╷ +5 │ "bob": "a", + │ ^^^^^ + ╵''', + ); + }); + }); +} + +void _hostedDependency() { + test('null', () async { + final dep = await _dependency(null); + expect(dep.version.toString(), 'any'); + expect(dep.hosted, isNull); + expect(dep.toString(), 'HostedDependency: any'); + }); + + test('empty map', () async { + final dep = await _dependency({}); + expect(dep.hosted, isNull); + expect(dep.toString(), 'HostedDependency: any'); + }); + + test('string version', () async { + final dep = await _dependency('^1.0.0'); + expect(dep.version.toString(), '^1.0.0'); + expect(dep.hosted, isNull); + expect(dep.toString(), 'HostedDependency: ^1.0.0'); + }); + + test('bad string version', () { + _expectThrows( + 'not a version', + r''' +line 4, column 10: Unsupported value for "dep". Could not parse version "not a version". Unknown text at "not a version". + ╷ +4 │ "dep": "not a version" + │ ^^^^^^^^^^^^^^^ + ╵''', + ); + }); + + test('map w/ just version', () async { + final dep = await _dependency({'version': '^1.0.0'}); + expect(dep.version.toString(), '^1.0.0'); + expect(dep.hosted, isNull); + expect(dep.toString(), 'HostedDependency: ^1.0.0'); + }); + + test('map w/ version and hosted as Map', () async { + final dep = await _dependency({ + 'version': '^1.0.0', + 'hosted': {'name': 'hosted_name', 'url': 'https://hosted_url'}, + }); + expect(dep.version.toString(), '^1.0.0'); + expect(dep.hosted!.name, 'hosted_name'); + expect(dep.hosted!.url.toString(), 'https://hosted_url'); + expect(dep.toString(), 'HostedDependency: ^1.0.0'); + }); + + test('map /w hosted as a map without name', () async { + final dep = await _dependency( + { + 'version': '^1.0.0', + 'hosted': {'url': 'https://hosted_url'}, + }, + skipTryPub: true, // todo: Unskip once pub supports this syntax + ); + expect(dep.version.toString(), '^1.0.0'); + expect(dep.hosted!.declaredName, isNull); + expect(dep.hosted!.name, 'dep'); + expect(dep.hosted!.url.toString(), 'https://hosted_url'); + expect(dep.toString(), 'HostedDependency: ^1.0.0'); + }); + + test('map w/ bad version value', () { + _expectThrows( + { + 'version': 'not a version', + 'hosted': {'name': 'hosted_name', 'url': 'hosted_url'}, + }, + r''' +line 5, column 15: Unsupported value for "version". Could not parse version "not a version". Unknown text at "not a version". + ╷ +5 │ "version": "not a version", + │ ^^^^^^^^^^^^^^^ + ╵''', + ); + }); + + test('map w/ extra keys should fail', () { + _expectThrows( + { + 'version': '^1.0.0', + 'hosted': {'name': 'hosted_name', 'url': 'hosted_url'}, + 'not_supported': null, + }, + r''' +line 10, column 4: Unrecognized keys: [not_supported]; supported keys: [sdk, git, path, hosted] + ╷ +10 │ "not_supported": null + │ ^^^^^^^^^^^^^^^ + ╵''', + ); + }); + + test('map w/ version and hosted as String', () async { + final dep = await _dependency( + {'version': '^1.0.0', 'hosted': 'hosted_url'}, + skipTryPub: true, // todo: Unskip once put supports this + ); + expect(dep.version.toString(), '^1.0.0'); + expect(dep.hosted!.declaredName, isNull); + expect(dep.hosted!.name, 'dep'); + expect(dep.hosted!.url, Uri.parse('hosted_url')); + expect(dep.toString(), 'HostedDependency: ^1.0.0'); + }); + + test('map w/ hosted as String', () async { + final dep = await _dependency({'hosted': 'hosted_url'}); + expect(dep.version, VersionConstraint.any); + expect(dep.hosted!.declaredName, isNull); + expect(dep.hosted!.name, 'dep'); + expect(dep.hosted!.url, Uri.parse('hosted_url')); + expect(dep.toString(), 'HostedDependency: any'); + }); + + test('map w/ null hosted should error', () { + _expectThrows( + {'hosted': null}, + r''' +line 5, column 4: These keys had `null` values, which is not allowed: [hosted] + ╷ +5 │ "hosted": null + │ ^^^^^^^^ + ╵''', + ); + }); + + test('map w/ null version is fine', () async { + final dep = await _dependency({'version': null}); + expect(dep.version, VersionConstraint.any); + expect(dep.hosted, isNull); + expect(dep.toString(), 'HostedDependency: any'); + }); +} + +void _sdkDependency() { + test('without version', () async { + final dep = await _dependency({'sdk': 'flutter'}); + expect(dep.sdk, 'flutter'); + expect(dep.version, VersionConstraint.any); + expect(dep.toString(), 'SdkDependency: flutter'); + }); + + test('with version', () async { + final dep = await _dependency( + {'sdk': 'flutter', 'version': '>=1.2.3 <2.0.0'}, + ); + expect(dep.sdk, 'flutter'); + expect(dep.version.toString(), '>=1.2.3 <2.0.0'); + expect(dep.toString(), 'SdkDependency: flutter'); + }); + + test('null content', () { + _expectThrowsContaining( + {'sdk': null}, + r"type 'Null' is not a subtype of type 'String'", + ); + }); + + test('number content', () { + _expectThrowsContaining( + {'sdk': 42}, + r"type 'int' is not a subtype of type 'String'", + ); + }); +} + +void _gitDependency() { + test('string', () async { + final dep = await _dependency({'git': 'url'}); + expect(dep.url.toString(), 'url'); + expect(dep.path, isNull); + expect(dep.ref, isNull); + expect(dep.toString(), 'GitDependency: url@url'); + }); + + test('string with version key is ignored', () async { + // Regression test for https://github.com/dart-lang/pubspec_parse/issues/13 + final dep = + await _dependency({'git': 'url', 'version': '^1.2.3'}); + expect(dep.url.toString(), 'url'); + expect(dep.path, isNull); + expect(dep.ref, isNull); + expect(dep.toString(), 'GitDependency: url@url'); + }); + + test('string with user@ URL', () async { + final skipTryParse = Platform.environment.containsKey('TRAVIS'); + if (skipTryParse) { + print('FYI: not validating git@ URI on travis due to failure'); + } + final dep = await _dependency( + {'git': 'git@localhost:dep.git'}, + skipTryPub: skipTryParse, + ); + expect(dep.url.toString(), 'ssh://git@localhost/dep.git'); + expect(dep.path, isNull); + expect(dep.ref, isNull); + expect(dep.toString(), 'GitDependency: url@ssh://git@localhost/dep.git'); + }); + + test('string with random extra key fails', () { + _expectThrows( + {'git': 'url', 'bob': '^1.2.3'}, + r''' +line 6, column 4: Unrecognized keys: [bob]; supported keys: [sdk, git, path, hosted] + ╷ +6 │ "bob": "^1.2.3" + │ ^^^^^ + ╵''', + ); + }); + + test('map', () async { + final dep = await _dependency({ + 'git': {'url': 'url', 'path': 'path', 'ref': 'ref'}, + }); + expect(dep.url.toString(), 'url'); + expect(dep.path, 'path'); + expect(dep.ref, 'ref'); + expect(dep.toString(), 'GitDependency: url@url'); + }); + + test('git - null content', () { + _expectThrows( + {'git': null}, + r''' +line 5, column 11: Unsupported value for "git". Must be a String or a Map. + ╷ +5 │ "git": null + │ ┌───────────^ +6 │ │ } + │ └──^ + ╵''', + ); + }); + + test('git - int content', () { + _expectThrows( + {'git': 42}, + r''' +line 5, column 11: Unsupported value for "git". Must be a String or a Map. + ╷ +5 │ "git": 42 + │ ┌───────────^ +6 │ │ } + │ └──^ + ╵''', + ); + }); + + test('git - empty map', () { + _expectThrowsContaining( + {'git': {}}, + r"type 'Null' is not a subtype of type 'String'", + ); + }); + + test('git - null url', () { + _expectThrowsContaining( + { + 'git': {'url': null}, + }, + r"type 'Null' is not a subtype of type 'String'", + ); + }); + + test('git - int url', () { + _expectThrowsContaining( + { + 'git': {'url': 42}, + }, + r"type 'int' is not a subtype of type 'String'", + ); + }); +} + +void _pathDependency() { + test('valid', () async { + final dep = await _dependency({'path': '../path'}); + expect(dep.path, '../path'); + expect(dep.toString(), 'PathDependency: path@../path'); + }); + + test('valid with version key is ignored', () async { + final dep = await _dependency( + {'path': '../path', 'version': '^1.2.3'}, + ); + expect(dep.path, '../path'); + expect(dep.toString(), 'PathDependency: path@../path'); + }); + + test('valid with random extra key fails', () { + _expectThrows( + {'path': '../path', 'bob': '^1.2.3'}, + r''' +line 6, column 4: Unrecognized keys: [bob]; supported keys: [sdk, git, path, hosted] + ╷ +6 │ "bob": "^1.2.3" + │ ^^^^^ + ╵''', + ); + }); + + test('null content', () { + _expectThrows( + {'path': null}, + r''' +line 5, column 12: Unsupported value for "path". Must be a String. + ╷ +5 │ "path": null + │ ┌────────────^ +6 │ │ } + │ └──^ + ╵''', + ); + }); + + test('int content', () { + _expectThrows( + {'path': 42}, + r''' +line 5, column 12: Unsupported value for "path". Must be a String. + ╷ +5 │ "path": 42 + │ ┌────────────^ +6 │ │ } + │ └──^ + ╵''', + ); + }); +} + +void _expectThrows(Object content, String expectedError) { + expectParseThrows( + { + 'name': 'sample', + 'dependencies': {'dep': content}, + }, + expectedError, + ); +} + +void _expectThrowsContaining(Object content, String errorText) { + expectParseThrowsContaining( + { + 'name': 'sample', + 'dependencies': {'dep': content}, + }, + errorText, + ); +} + +Future _dependency( + Object? content, { + bool skipTryPub = false, +}) async { + final value = await parse( + { + ...defaultPubspec, + 'dependencies': {'dep': content}, + }, + skipTryPub: skipTryPub, + ); + expect(value.name, 'sample'); + expect(value.dependencies, hasLength(1)); + + final entry = value.dependencies.entries.single; + expect(entry.key, 'dep'); + + return entry.value as T; +} diff --git a/pkgs/pubspec_parse/test/ensure_build_test.dart b/pkgs/pubspec_parse/test/ensure_build_test.dart new file mode 100644 index 000000000..0e4371c13 --- /dev/null +++ b/pkgs/pubspec_parse/test/ensure_build_test.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@Timeout.factor(2) +@TestOn('vm') +@Tags(['presubmit-only']) +library; + +import 'package:build_verify/build_verify.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'ensure_build', + () => expectBuildClean(packageRelativeDirectory: 'pkgs/pubspec_parse/'), + ); +} diff --git a/pkgs/pubspec_parse/test/git_uri_test.dart b/pkgs/pubspec_parse/test/git_uri_test.dart new file mode 100644 index 000000000..be89ba8a0 --- /dev/null +++ b/pkgs/pubspec_parse/test/git_uri_test.dart @@ -0,0 +1,25 @@ +import 'package:pubspec_parse/src/dependency.dart'; +import 'package:test/test.dart'; + +void main() { + for (var item in { + 'git@github.com:google/grinder.dart.git': + 'ssh://git@github.com/google/grinder.dart.git', + 'host.xz:path/to/repo.git/': 'ssh://host.xz/path/to/repo.git/', + 'http:path/to/repo.git/': 'ssh://http/path/to/repo.git/', + 'file:path/to/repo.git/': 'ssh://file/path/to/repo.git/', + './foo:bar': 'foo%3Abar', + '/path/to/repo.git/': '/path/to/repo.git/', + 'file:///path/to/repo.git/': 'file:///path/to/repo.git/', + }.entries) { + test(item.key, () { + final uri = parseGitUri(item.key); + + printOnFailure( + [uri.scheme, uri.userInfo, uri.host, uri.port, uri.path].join('\n'), + ); + + expect(uri, Uri.parse(item.value)); + }); + } +} diff --git a/pkgs/pubspec_parse/test/parse_test.dart b/pkgs/pubspec_parse/test/parse_test.dart new file mode 100644 index 000000000..6251f41fb --- /dev/null +++ b/pkgs/pubspec_parse/test/parse_test.dart @@ -0,0 +1,715 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: lines_longer_than_80_chars + +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +void main() { + test('minimal set values', () async { + final value = await parse(defaultPubspec); + expect(value.name, 'sample'); + expect(value.version, isNull); + expect(value.publishTo, isNull); + expect(value.description, isNull); + expect(value.homepage, isNull); + expect(value.author, isNull); + expect(value.authors, isEmpty); + expect( + value.environment, + {'sdk': VersionConstraint.parse('>=2.12.0 <3.0.0')}, + ); + expect(value.documentation, isNull); + expect(value.dependencies, isEmpty); + expect(value.devDependencies, isEmpty); + expect(value.dependencyOverrides, isEmpty); + expect(value.flutter, isNull); + expect(value.repository, isNull); + expect(value.issueTracker, isNull); + expect(value.screenshots, isEmpty); + }); + + test('all fields set', () async { + final version = Version.parse('1.2.3'); + final sdkConstraint = VersionConstraint.parse('>=2.12.0 <3.0.0'); + final value = await parse({ + 'name': 'sample', + 'version': version.toString(), + 'publish_to': 'none', + 'author': 'name@example.com', + 'environment': {'sdk': sdkConstraint.toString()}, + 'description': 'description', + 'homepage': 'homepage', + 'documentation': 'documentation', + 'repository': 'https://github.com/example/repo', + 'issue_tracker': 'https://github.com/example/repo/issues', + 'funding': [ + 'https://patreon.com/example', + ], + 'topics': ['widget', 'button'], + 'ignored_advisories': ['111', '222'], + 'screenshots': [ + {'description': 'my screenshot', 'path': 'path/to/screenshot'}, + ], + }); + expect(value.name, 'sample'); + expect(value.version, version); + expect(value.publishTo, 'none'); + expect(value.description, 'description'); + expect(value.homepage, 'homepage'); + expect(value.author, 'name@example.com'); + expect(value.authors, ['name@example.com']); + expect(value.environment, hasLength(1)); + expect(value.environment, containsPair('sdk', sdkConstraint)); + expect(value.documentation, 'documentation'); + expect(value.dependencies, isEmpty); + expect(value.devDependencies, isEmpty); + expect(value.dependencyOverrides, isEmpty); + expect(value.repository, Uri.parse('https://github.com/example/repo')); + expect( + value.issueTracker, + Uri.parse('https://github.com/example/repo/issues'), + ); + expect(value.funding, hasLength(1)); + expect(value.funding!.single.toString(), 'https://patreon.com/example'); + expect(value.topics, hasLength(2)); + expect(value.topics!.first, 'widget'); + expect(value.topics!.last, 'button'); + expect(value.ignoredAdvisories, hasLength(2)); + expect(value.ignoredAdvisories!.first, '111'); + expect(value.ignoredAdvisories!.last, '222'); + expect(value.screenshots, hasLength(1)); + expect(value.screenshots!.first.description, 'my screenshot'); + expect(value.screenshots!.first.path, 'path/to/screenshot'); + }); + + test('environment values can be null', () async { + final value = await parse( + { + 'name': 'sample', + 'environment': { + 'sdk': '>=2.12.0 <3.0.0', + 'bob': null, + }, + }, + skipTryPub: true, + ); + expect(value.name, 'sample'); + expect(value.environment, hasLength(2)); + expect(value.environment, containsPair('bob', isNull)); + }); + + group('publish_to', () { + for (var entry in { + 42: "Unsupported value for \"publish_to\". type 'int' is not a subtype of type 'String?'", + '##not a uri!': r''' +line 3, column 16: Unsupported value for "publish_to". Must be an http or https URL. + ╷ +3 │ "publish_to": "##not a uri!" + │ ^^^^^^^^^^^^^^ + ╵''', + '/cool/beans': r''' +line 3, column 16: Unsupported value for "publish_to". Must be an http or https URL. + ╷ +3 │ "publish_to": "/cool/beans" + │ ^^^^^^^^^^^^^ + ╵''', + 'file:///Users/kevmoo/': r''' +line 3, column 16: Unsupported value for "publish_to". Must be an http or https URL. + ╷ +3 │ "publish_to": "file:///Users/kevmoo/" + │ ^^^^^^^^^^^^^^^^^^^^^^^ + ╵''', + }.entries) { + test('cannot be `${entry.key}`', () { + expectParseThrowsContaining( + {'name': 'sample', 'publish_to': entry.key}, + entry.value, + skipTryPub: true, + ); + }); + } + + for (var entry in { + null: null, + 'http': 'http://example.com', + 'https': 'https://example.com', + 'none': 'none', + }.entries) { + test('can be ${entry.key}', () async { + final value = await parse({ + ...defaultPubspec, + 'publish_to': entry.value, + }); + expect(value.publishTo, entry.value); + }); + } + }); + + group('author, authors', () { + test('one author', () async { + final value = await parse({ + ...defaultPubspec, + 'author': 'name@example.com', + }); + expect(value.author, 'name@example.com'); + expect(value.authors, ['name@example.com']); + }); + + test('one author, via authors', () async { + final value = await parse({ + ...defaultPubspec, + 'authors': ['name@example.com'], + }); + expect(value.author, 'name@example.com'); + expect(value.authors, ['name@example.com']); + }); + + test('many authors', () async { + final value = await parse({ + ...defaultPubspec, + 'authors': ['name@example.com', 'name2@example.com'], + }); + expect(value.author, isNull); + expect(value.authors, ['name@example.com', 'name2@example.com']); + }); + + test('author and authors', () async { + final value = await parse({ + ...defaultPubspec, + 'author': 'name@example.com', + 'authors': ['name2@example.com'], + }); + expect(value.author, isNull); + expect(value.authors, ['name@example.com', 'name2@example.com']); + }); + + test('duplicate author values', () async { + final value = await parse({ + ...defaultPubspec, + 'author': 'name@example.com', + 'authors': ['name@example.com', 'name@example.com'], + }); + expect(value.author, 'name@example.com'); + expect(value.authors, ['name@example.com']); + }); + + test('flutter', () async { + final value = await parse({ + ...defaultPubspec, + 'flutter': {'key': 'value'}, + }); + expect(value.flutter, {'key': 'value'}); + }); + }); + + group('invalid', () { + test('null', () { + expectParseThrows( + null, + r''' +line 1, column 1: Not a map + ╷ +1 │ null + │ ^^^^ + ╵''', + ); + }); + test('empty string', () { + expectParseThrows( + '', + r''' +line 1, column 1: Not a map + ╷ +1 │ "" + │ ^^ + ╵''', + ); + }); + test('array', () { + expectParseThrows( + [], + r''' +line 1, column 1: Not a map + ╷ +1 │ [] + │ ^^ + ╵''', + ); + }); + + test('missing name', () { + expectParseThrowsContaining( + {}, + "Missing key \"name\". type 'Null' is not a subtype of type 'String'", + ); + }); + + test('null name value', () { + expectParseThrowsContaining( + {'name': null}, + "Unsupported value for \"name\". type 'Null' is not a subtype of type 'String'", + ); + }); + + test('empty name value', () { + expectParseThrows( + {'name': ''}, + r''' +line 2, column 10: Unsupported value for "name". "name" cannot be empty. + ╷ +2 │ "name": "" + │ ^^ + ╵''', + ); + }); + + test('"dart" is an invalid environment key', () { + expectParseThrows( + { + 'name': 'sample', + 'environment': {'dart': 'cool'}, + }, + r''' +line 4, column 3: Use "sdk" to for Dart SDK constraints. + ╷ +4 │ "dart": "cool" + │ ^^^^^^ + ╵''', + ); + }); + + test('environment values cannot be int', () { + expectParseThrows( + { + 'name': 'sample', + 'environment': {'sdk': 42}, + }, + r''' +line 4, column 10: Unsupported value for "sdk". `42` is not a String. + ╷ +4 │ "sdk": 42 + │ ┌──────────^ +5 │ │ } + │ └─^ + ╵''', + ); + }); + + test('version', () { + expectParseThrows( + {'name': 'sample', 'version': 'invalid'}, + r''' +line 3, column 13: Unsupported value for "version". Could not parse "invalid". + ╷ +3 │ "version": "invalid" + │ ^^^^^^^^^ + ╵''', + ); + }); + + test('invalid environment value', () { + expectParseThrows( + { + 'name': 'sample', + 'environment': {'sdk': 'silly'}, + }, + r''' +line 4, column 10: Unsupported value for "sdk". Could not parse version "silly". Unknown text at "silly". + ╷ +4 │ "sdk": "silly" + │ ^^^^^^^ + ╵''', + ); + }); + + test('bad repository url', () { + expectParseThrowsContaining( + { + ...defaultPubspec, + 'repository': {'x': 'y'}, + }, + "Unsupported value for \"repository\". type 'YamlMap' is not a subtype of type 'String'", + skipTryPub: true, + ); + }); + + test('bad issue_tracker url', () { + expectParseThrowsContaining( + { + 'name': 'sample', + 'issue_tracker': {'x': 'y'}, + }, + "Unsupported value for \"issue_tracker\". type 'YamlMap' is not a subtype of type 'String'", + skipTryPub: true, + ); + }); + }); + + group('funding', () { + test('not a list', () { + expectParseThrowsContaining( + { + ...defaultPubspec, + 'funding': 1, + }, + "Unsupported value for \"funding\". type 'int' is not a subtype of type 'List?'", + skipTryPub: true, + ); + }); + + test('not an uri', () { + expectParseThrowsContaining( + { + ...defaultPubspec, + 'funding': [1], + }, + "Unsupported value for \"funding\". type 'int' is not a subtype of type 'String'", + skipTryPub: true, + ); + }); + + test('not an uri', () { + expectParseThrows( + { + ...defaultPubspec, + 'funding': ['ht tps://example.com/'], + }, + r''' +line 6, column 13: Unsupported value for "funding". Illegal scheme character at offset 2. + ╷ +6 │ "funding": [ + │ ┌─────────────^ +7 │ │ "ht tps://example.com/" +8 │ └ ] + ╵''', + skipTryPub: true, + ); + }); + }); + group('topics', () { + test('not a list', () { + expectParseThrowsContaining( + { + ...defaultPubspec, + 'topics': 1, + }, + "Unsupported value for \"topics\". type 'int' is not a subtype of type 'List?'", + skipTryPub: true, + ); + }); + + test('not a string', () { + expectParseThrowsContaining( + { + ...defaultPubspec, + 'topics': [1], + }, + "Unsupported value for \"topics\". type 'int' is not a subtype of type 'String'", + skipTryPub: true, + ); + }); + + test('invalid data - lenient', () async { + final value = await parse( + { + ...defaultPubspec, + 'topics': [1], + }, + skipTryPub: true, + lenient: true, + ); + expect(value.name, 'sample'); + expect(value.topics, isNull); + }); + }); + + group('ignored_advisories', () { + test('not a list', () { + expectParseThrowsContaining( + { + ...defaultPubspec, + 'ignored_advisories': 1, + }, + "Unsupported value for \"ignored_advisories\". type 'int' is not a subtype of type 'List?'", + skipTryPub: true, + ); + }); + + test('not a string', () { + expectParseThrowsContaining( + { + ...defaultPubspec, + 'ignored_advisories': [1], + }, + "Unsupported value for \"ignored_advisories\". type 'int' is not a subtype of type 'String'", + skipTryPub: true, + ); + }); + + test('invalid data - lenient', () async { + final value = await parse( + { + ...defaultPubspec, + 'ignored_advisories': [1], + }, + skipTryPub: true, + lenient: true, + ); + expect(value.name, 'sample'); + expect(value.ignoredAdvisories, isNull); + }); + }); + + group('screenshots', () { + test('one screenshot', () async { + final value = await parse({ + ...defaultPubspec, + 'screenshots': [ + {'description': 'my screenshot', 'path': 'path/to/screenshot'}, + ], + }); + expect(value.screenshots, hasLength(1)); + expect(value.screenshots!.first.description, 'my screenshot'); + expect(value.screenshots!.first.path, 'path/to/screenshot'); + }); + + test('many screenshots', () async { + final value = await parse({ + ...defaultPubspec, + 'screenshots': [ + {'description': 'my screenshot', 'path': 'path/to/screenshot'}, + { + 'description': 'my second screenshot', + 'path': 'path/to/screenshot2', + }, + ], + }); + expect(value.screenshots, hasLength(2)); + expect(value.screenshots!.first.description, 'my screenshot'); + expect(value.screenshots!.first.path, 'path/to/screenshot'); + expect(value.screenshots!.last.description, 'my second screenshot'); + expect(value.screenshots!.last.path, 'path/to/screenshot2'); + }); + + test('one screenshot plus invalid entries', () async { + final value = await parse({ + ...defaultPubspec, + 'screenshots': [ + 42, + { + 'description': 'my screenshot', + 'path': 'path/to/screenshot', + 'extraKey': 'not important', + }, + 'not a screenshot', + ], + }); + expect(value.screenshots, hasLength(1)); + expect(value.screenshots!.first.description, 'my screenshot'); + expect(value.screenshots!.first.path, 'path/to/screenshot'); + }); + + test('invalid entries', () async { + final value = await parse({ + ...defaultPubspec, + 'screenshots': [ + 42, + 'not a screenshot', + ], + }); + expect(value.screenshots, isEmpty); + }); + + test('missing key `dessription', () { + expectParseThrows( + { + ...defaultPubspec, + 'screenshots': [ + {'path': 'my/path'}, + ], + }, + r''' +line 7, column 3: Missing key "description". Missing required key `description` + ╷ +7 │ ┌ { +8 │ │ "path": "my/path" +9 │ └ } + ╵''', + skipTryPub: true, + ); + }); + + test('missing key `path`', () { + expectParseThrows( + { + ...defaultPubspec, + 'screenshots': [ + {'description': 'my screenshot'}, + ], + }, + r''' +line 7, column 3: Missing key "path". Missing required key `path` + ╷ +7 │ ┌ { +8 │ │ "description": "my screenshot" +9 │ └ } + ╵''', + skipTryPub: true, + ); + }); + + test('Value of description not a String`', () { + expectParseThrows( + { + ...defaultPubspec, + 'screenshots': [ + {'description': 42}, + ], + }, + r''' +line 8, column 19: Unsupported value for "description". `42` is not a String + ╷ +8 │ "description": 42 + │ ┌───────────────────^ +9 │ │ } + │ └──^ + ╵''', + skipTryPub: true, + ); + }); + + test('Value of path not a String`', () { + expectParseThrows( + { + ...defaultPubspec, + 'screenshots': [ + { + 'description': '', + 'path': 42, + }, + ], + }, + r''' +line 9, column 12: Unsupported value for "path". `42` is not a String + ╷ +9 │ "path": 42 + │ ┌────────────^ +10 │ │ } + │ └──^ + ╵''', + skipTryPub: true, + ); + }); + + test('invalid screenshot - lenient', () async { + final value = await parse( + { + ...defaultPubspec, + 'screenshots': 'Invalid value', + }, + lenient: true, + ); + expect(value.name, 'sample'); + expect(value.screenshots, isEmpty); + }); + }); + + group('lenient', () { + test('null', () { + expectParseThrows( + null, + r''' +line 1, column 1: Not a map + ╷ +1 │ null + │ ^^^^ + ╵''', + lenient: true, + ); + }); + + test('empty string', () { + expectParseThrows( + '', + r''' +line 1, column 1: Not a map + ╷ +1 │ "" + │ ^^ + ╵''', + lenient: true, + ); + }); + + test('name cannot be empty', () { + expectParseThrowsContaining( + {}, + "Missing key \"name\". type 'Null' is not a subtype of type 'String'", + lenient: true, + ); + }); + + test('bad repository url', () async { + final value = await parse( + { + ...defaultPubspec, + 'repository': {'x': 'y'}, + }, + lenient: true, + ); + expect(value.name, 'sample'); + expect(value.repository, isNull); + }); + + test('bad issue_tracker url', () async { + final value = await parse( + { + ...defaultPubspec, + 'issue_tracker': {'x': 'y'}, + }, + lenient: true, + ); + expect(value.name, 'sample'); + expect(value.issueTracker, isNull); + }); + + test('multiple bad values', () async { + final value = await parse( + { + ...defaultPubspec, + 'repository': {'x': 'y'}, + 'issue_tracker': {'x': 'y'}, + }, + lenient: true, + ); + expect(value.name, 'sample'); + expect(value.repository, isNull); + expect(value.issueTracker, isNull); + }); + + test('deep error throws with lenient', () { + expect( + () => parse( + { + 'name': 'sample', + 'dependencies': { + 'foo': { + 'git': {'url': 1}, + }, + }, + 'issue_tracker': {'x': 'y'}, + }, + skipTryPub: true, + lenient: true, + ), + throwsException, + ); + }); + }); +} diff --git a/pkgs/pubspec_parse/test/pub_utils.dart b/pkgs/pubspec_parse/test/pub_utils.dart new file mode 100644 index 000000000..a60aa2a99 --- /dev/null +++ b/pkgs/pubspec_parse/test/pub_utils.dart @@ -0,0 +1,88 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; +import 'package:test_process/test_process.dart'; + +Future tryPub(String content) async { + await d.file('pubspec.yaml', content).create(); + + final proc = await TestProcess.start( + Platform.resolvedExecutable, + ['pub', 'get', '--offline'], + workingDirectory: d.sandbox, + // Don't pass current process VM options to child + environment: Map.from(Platform.environment)..remove('DART_VM_OPTIONS'), + ); + + final result = await ProcResult.fromTestProcess(proc); + + printOnFailure( + [ + '-----BEGIN pub output-----', + result.toString().trim(), + '-----END pub output-----', + ].join('\n'), + ); + + if (result.exitCode == 0) { + final lockContent = + File(p.join(d.sandbox, 'pubspec.lock')).readAsStringSync(); + + printOnFailure( + [ + '-----BEGIN pubspec.lock-----', + lockContent.trim(), + '-----END pubspec.lock-----', + ].join('\n'), + ); + } + + return result; +} + +class ProcResult { + final int exitCode; + final List lines; + + bool get cleanParse => exitCode == 0 || exitCode == 66 || exitCode == 69; + + ProcResult(this.exitCode, this.lines); + + static Future fromTestProcess(TestProcess proc) async { + final items = []; + + final values = await Future.wait([ + proc.exitCode, + proc.stdoutStream().forEach((line) => items.add(ProcLine(false, line))), + proc.stderrStream().forEach((line) => items.add(ProcLine(true, line))), + ]); + + return ProcResult(values[0] as int, items); + } + + @override + String toString() { + final buffer = StringBuffer('Exit code: $exitCode'); + for (var line in lines) { + buffer.write('\n$line'); + } + return buffer.toString(); + } +} + +class ProcLine { + final bool isError; + final String line; + + ProcLine(this.isError, this.line); + + @override + String toString() => '${isError ? 'err' : 'out'} $line'; +} diff --git a/pkgs/pubspec_parse/test/test_utils.dart b/pkgs/pubspec_parse/test/test_utils.dart new file mode 100644 index 000000000..cc46522b7 --- /dev/null +++ b/pkgs/pubspec_parse/test/test_utils.dart @@ -0,0 +1,157 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:checked_yaml/checked_yaml.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/test.dart'; + +import 'pub_utils.dart'; + +const defaultPubspec = { + 'name': 'sample', + 'environment': {'sdk': '>=2.12.0 <3.0.0'}, +}; + +String _encodeJson(Object? input) => + const JsonEncoder.withIndent(' ').convert(input); + +Matcher _throwsParsedYamlException(String prettyValue) => throwsA( + const TypeMatcher().having( + (e) { + final message = e.formattedMessage; + printOnFailure("Actual error format:\nr'''\n$message'''"); + _printDebugParsedYamlException(e); + return message; + }, + 'formattedMessage', + prettyValue, + ), + ); + +void _printDebugParsedYamlException(ParsedYamlException e) { + var innerError = e.innerError; + StackTrace? innerStack; + + if (innerError is CheckedFromJsonException) { + final cfje = innerError; + + if (cfje.innerError != null) { + innerError = cfje.innerError; + innerStack = cfje.innerStack; + } + } + + if (innerError != null) { + final items = [innerError]; + if (innerStack != null) { + items.add(Trace.format(innerStack)); + } + + final content = + LineSplitter.split(items.join('\n')).map((e) => ' $e').join('\n'); + + printOnFailure('Inner error details:\n$content'); + } +} + +Future parse( + Object? content, { + bool quietOnError = false, + bool skipTryPub = false, + bool lenient = false, +}) async { + final encoded = _encodeJson(content); + + ProcResult? pubResult; + if (!skipTryPub) { + // ignore: deprecated_member_use + pubResult = await tryPub(encoded); + expect(pubResult, isNotNull); + } + + try { + final value = Pubspec.parse(encoded, lenient: lenient); + + if (pubResult != null) { + addTearDown(() { + expect( + pubResult!.cleanParse, + isTrue, + reason: + 'On success, parsing from the pub client should also succeed.', + ); + }); + } + return value; + } catch (e) { + if (pubResult != null) { + addTearDown(() { + expect( + pubResult!.cleanParse, + isFalse, + reason: 'On failure, parsing from the pub client should also fail.', + ); + }); + } + if (e is ParsedYamlException) { + if (!quietOnError) { + _printDebugParsedYamlException(e); + } + } + rethrow; + } +} + +void expectParseThrows( + Object? content, + String expectedError, { + bool skipTryPub = false, + bool lenient = false, +}) => + expect( + () => parse( + content, + lenient: lenient, + quietOnError: true, + skipTryPub: skipTryPub, + ), + _throwsParsedYamlException(expectedError), + ); + +void expectParseThrowsContaining( + Object? content, + String errorFragment, { + bool skipTryPub = false, + bool lenient = false, +}) { + expect( + () => parse( + content, + lenient: lenient, + quietOnError: true, + skipTryPub: skipTryPub, + ), + _throwsParsedYamlExceptionContaining(errorFragment), + ); +} + +// ignore: prefer_expression_function_bodies +Matcher _throwsParsedYamlExceptionContaining(String errorFragment) { + return throwsA( + const TypeMatcher().having( + (e) { + final message = e.formattedMessage; + printOnFailure("Actual error format:\nr'''\n$message'''"); + _printDebugParsedYamlException(e); + return message; + }, + 'formattedMessage', + contains(errorFragment), + ), + ); +} diff --git a/pkgs/source_maps/.gitignore b/pkgs/source_maps/.gitignore new file mode 100644 index 000000000..f73b2f917 --- /dev/null +++ b/pkgs/source_maps/.gitignore @@ -0,0 +1,4 @@ +.dart_tool/ +.packages +.pub/ +pubspec.lock diff --git a/pkgs/source_maps/CHANGELOG.md b/pkgs/source_maps/CHANGELOG.md new file mode 100644 index 000000000..b06ac72ea --- /dev/null +++ b/pkgs/source_maps/CHANGELOG.md @@ -0,0 +1,133 @@ +## 0.10.14-wip + +## 0.10.13 + +* Require Dart 3.3 +* Move to `dart-lang/tools` monorepo. + +## 0.10.12 + +* Add additional types at API boundaries. + +## 0.10.11 + +* Populate the pubspec `repository` field. +* Update the source map documentation link in the readme. + +## 0.10.10 + +* Stable release for null safety. + +## 0.10.9 + +* Fix a number of document comment issues. +* Allow parsing source map files with a missing `names` field. + +## 0.10.8 + +* Preserve source-map extensions in `SingleMapping`. Extensions are keys in the + json map that start with `"x_"`. + +## 0.10.7 + +* Set max SDK version to `<3.0.0`, and adjust other dependencies. + +## 0.10.6 + +* Require version 2.0.0 of the Dart SDK. + +## 0.10.5 + +* Add a `SingleMapping.files` field which provides access to `SourceFile`s + representing the `"sourcesContent"` fields in the source map. + +* Add an `includeSourceContents` flag to `SingleMapping.toJson()` which + indicates whether to include source file contents in the source map. + +## 0.10.4 +* Implement `highlight` in `SourceMapFileSpan`. +* Require version `^1.3.0` of `source_span`. + +## 0.10.3 + * Add `addMapping` and `containsMapping` members to `MappingBundle`. + +## 0.10.2 + * Support for extended source map format. + * Polish `MappingBundle.spanFor` handling of URIs that have a suffix that + exactly match a source map in the MappingBundle. + +## 0.10.1+5 + * Fix strong mode warning in test. + +## 0.10.1+4 + +* Extend `MappingBundle.spanFor` to accept requests for output files that + don't have source maps. + +## 0.10.1+3 + +* Add `MappingBundle` class that handles extended source map format that + supports source maps for multiple output files in a single mapper. + Extend `Mapping.spanFor` API to accept a uri parameter that is optional + for normal source maps but required for MappingBundle source maps. + +## 0.10.1+2 + +* Fix more strong mode warnings. + +## 0.10.1+1 + +* Fix all strong mode warnings. + +## 0.10.1 + +* Add a `mapUrl` named argument to `parse` and `parseJson`. This argument is + used to resolve source URLs for source spans. + +## 0.10.0+2 + +* Fix analyzer error (FileSpan has a new field since `source_span` 1.1.1) + +## 0.10.0+1 + +* Remove an unnecessary warning printed when the "file" field is missing from a + Json formatted source map. This field is optional and its absence is not + unusual. + +## 0.10.0 + +* Remove the `Span`, `Location` and `SourceFile` classes. Use the + corresponding `source_span` classes instead. + +## 0.9.4 + +* Update `SpanFormatException` with `source` and `offset`. + +* All methods that take `Span`s, `Location`s, and `SourceFile`s as inputs now + also accept the corresponding `source_span` classes as well. Using the old + classes is now deprecated and will be unsupported in version 0.10.0. + +## 0.9.3 + +* Support writing SingleMapping objects to source map version 3 format. +* Support the `sourceRoot` field in the SingleMapping class. +* Support updating the `targetUrl` field in the SingleMapping class. + +## 0.9.2+2 + +* Fix a bug in `FixedSpan.getLocationMessage`. + +## 0.9.2+1 + +* Minor readability improvements to `FixedSpan.getLocationMessage` and + `SpanException.toString`. + +## 0.9.2 + +* Add `SpanException` and `SpanFormatException` classes. + +## 0.9.1 + +* Support unmapped areas in source maps. + +* Increase the readability of location messages. diff --git a/pkgs/source_maps/LICENSE b/pkgs/source_maps/LICENSE new file mode 100644 index 000000000..162572a44 --- /dev/null +++ b/pkgs/source_maps/LICENSE @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/source_maps/README.md b/pkgs/source_maps/README.md new file mode 100644 index 000000000..cf8029177 --- /dev/null +++ b/pkgs/source_maps/README.md @@ -0,0 +1,25 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/source_maps.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/source_maps.yaml) +[![pub package](https://img.shields.io/pub/v/source_maps.svg)](https://pub.dev/packages/source_maps) +[![package publisher](https://img.shields.io/pub/publisher/source_maps.svg)](https://pub.dev/packages/source_maps/publisher) + +This project implements a Dart pub package to work with source maps. + +## Docs and usage + +The implementation is based on the [source map version 3 spec][spec] which was +originated from the [Closure Compiler][closure] and has been implemented in +Chrome and Firefox. + +In this package we provide: + + * Data types defining file locations and spans: these are not part of the + original source map specification. These data types are great for tracking + source locations on source maps, but they can also be used by tools to + reporting useful error messages that include on source locations. + * A builder that creates a source map programmatically and produces the encoded + source map format. + * A parser that reads the source map format and provides APIs to read the + mapping information. + +[closure]: https://github.com/google/closure-compiler/wiki/Source-Maps +[spec]: https://docs.google.com/a/google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit diff --git a/pkgs/source_maps/analysis_options.yaml b/pkgs/source_maps/analysis_options.yaml new file mode 100644 index 000000000..d978f811c --- /dev/null +++ b/pkgs/source_maps/analysis_options.yaml @@ -0,0 +1 @@ +include: package:dart_flutter_team_lints/analysis_options.yaml diff --git a/pkgs/source_maps/lib/builder.dart b/pkgs/source_maps/lib/builder.dart new file mode 100644 index 000000000..9043c6326 --- /dev/null +++ b/pkgs/source_maps/lib/builder.dart @@ -0,0 +1,84 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Contains a builder object useful for creating source maps programatically. +library; + +// TODO(sigmund): add a builder for multi-section mappings. + +import 'dart:convert'; + +import 'package:source_span/source_span.dart'; + +import 'parser.dart'; +import 'src/source_map_span.dart'; + +/// Builds a source map given a set of mappings. +class SourceMapBuilder { + final List _entries = []; + + /// Adds an entry mapping the [targetOffset] to [source]. + void addFromOffset(SourceLocation source, SourceFile targetFile, + int targetOffset, String identifier) { + ArgumentError.checkNotNull(targetFile, 'targetFile'); + _entries.add(Entry(source, targetFile.location(targetOffset), identifier)); + } + + /// Adds an entry mapping [target] to [source]. + /// + /// If [isIdentifier] is true or if [target] is a [SourceMapSpan] with + /// `isIdentifier` set to true, this entry is considered to represent an + /// identifier whose value will be stored in the source map. [isIdentifier] + /// takes precedence over [target]'s `isIdentifier` value. + void addSpan(SourceSpan source, SourceSpan target, {bool? isIdentifier}) { + isIdentifier ??= source is SourceMapSpan ? source.isIdentifier : false; + + var name = isIdentifier ? source.text : null; + _entries.add(Entry(source.start, target.start, name)); + } + + /// Adds an entry mapping [target] to [source]. + void addLocation( + SourceLocation source, SourceLocation target, String? identifier) { + _entries.add(Entry(source, target, identifier)); + } + + /// Encodes all mappings added to this builder as a json map. + Map build(String fileUrl) { + return SingleMapping.fromEntries(_entries, fileUrl).toJson(); + } + + /// Encodes all mappings added to this builder as a json string. + String toJson(String fileUrl) => jsonEncode(build(fileUrl)); +} + +/// An entry in the source map builder. +class Entry implements Comparable { + /// Span denoting the original location in the input source file + final SourceLocation source; + + /// Span indicating the corresponding location in the target file. + final SourceLocation target; + + /// An identifier name, when this location is the start of an identifier. + final String? identifierName; + + /// Creates a new [Entry] mapping [target] to [source]. + Entry(this.source, this.target, this.identifierName); + + /// Implements [Comparable] to ensure that entries are ordered by their + /// location in the target file. We sort primarily by the target offset + /// because source map files are encoded by printing each mapping in order as + /// they appear in the target file. + @override + int compareTo(Entry other) { + var res = target.compareTo(other.target); + if (res != 0) return res; + res = source.sourceUrl + .toString() + .compareTo(other.source.sourceUrl.toString()); + if (res != 0) return res; + return source.compareTo(other.source); + } +} diff --git a/pkgs/source_maps/lib/parser.dart b/pkgs/source_maps/lib/parser.dart new file mode 100644 index 000000000..590dfc682 --- /dev/null +++ b/pkgs/source_maps/lib/parser.dart @@ -0,0 +1,718 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Contains the top-level function to parse source maps version 3. +library; + +import 'dart:convert'; + +import 'package:source_span/source_span.dart'; + +import 'builder.dart' as builder; +import 'src/source_map_span.dart'; +import 'src/utils.dart'; +import 'src/vlq.dart'; + +/// Parses a source map directly from a json string. +/// +/// [mapUrl], which may be either a [String] or a [Uri], indicates the URL of +/// the source map file itself. If it's passed, any URLs in the source +/// map will be interpreted as relative to this URL when generating spans. +// TODO(sigmund): evaluate whether other maps should have the json parsed, or +// the string represenation. +// TODO(tjblasi): Ignore the first line of [jsonMap] if the JSON safety string +// `)]}'` begins the string representation of the map. +Mapping parse(String jsonMap, + {Map? otherMaps, /*String|Uri*/ Object? mapUrl}) => + parseJson(jsonDecode(jsonMap) as Map, otherMaps: otherMaps, mapUrl: mapUrl); + +/// Parses a source map or source map bundle directly from a json string. +/// +/// [mapUrl], which may be either a [String] or a [Uri], indicates the URL of +/// the source map file itself. If it's passed, any URLs in the source +/// map will be interpreted as relative to this URL when generating spans. +Mapping parseExtended(String jsonMap, + {Map? otherMaps, /*String|Uri*/ Object? mapUrl}) => + parseJsonExtended(jsonDecode(jsonMap), + otherMaps: otherMaps, mapUrl: mapUrl); + +/// Parses a source map or source map bundle. +/// +/// [mapUrl], which may be either a [String] or a [Uri], indicates the URL of +/// the source map file itself. If it's passed, any URLs in the source +/// map will be interpreted as relative to this URL when generating spans. +Mapping parseJsonExtended(/*List|Map*/ Object? json, + {Map? otherMaps, /*String|Uri*/ Object? mapUrl}) { + if (json is List) { + return MappingBundle.fromJson(json, mapUrl: mapUrl); + } + return parseJson(json as Map); +} + +/// Parses a source map. +/// +/// [mapUrl], which may be either a [String] or a [Uri], indicates the URL of +/// the source map file itself. If it's passed, any URLs in the source +/// map will be interpreted as relative to this URL when generating spans. +Mapping parseJson(Map map, + {Map? otherMaps, /*String|Uri*/ Object? mapUrl}) { + if (map['version'] != 3) { + throw ArgumentError('unexpected source map version: ${map["version"]}. ' + 'Only version 3 is supported.'); + } + + if (map.containsKey('sections')) { + if (map.containsKey('mappings') || + map.containsKey('sources') || + map.containsKey('names')) { + throw const FormatException('map containing "sections" ' + 'cannot contain "mappings", "sources", or "names".'); + } + return MultiSectionMapping.fromJson(map['sections'] as List, otherMaps, + mapUrl: mapUrl); + } + return SingleMapping.fromJson(map.cast(), mapUrl: mapUrl); +} + +/// A mapping parsed out of a source map. +abstract class Mapping { + /// Returns the span associated with [line] and [column]. + /// + /// [uri] is the optional location of the output file to find the span for + /// to disambiguate cases where a mapping may have different mappings for + /// different output files. + SourceMapSpan? spanFor(int line, int column, + {Map? files, String? uri}); + + /// Returns the span associated with [location]. + SourceMapSpan? spanForLocation(SourceLocation location, + {Map? files}) { + return spanFor(location.line, location.column, + uri: location.sourceUrl?.toString(), files: files); + } +} + +/// A meta-level map containing sections. +class MultiSectionMapping extends Mapping { + /// For each section, the start line offset. + final List _lineStart = []; + + /// For each section, the start column offset. + final List _columnStart = []; + + /// For each section, the actual source map information, which is not adjusted + /// for offsets. + final List _maps = []; + + /// Creates a section mapping from json. + MultiSectionMapping.fromJson(List sections, Map? otherMaps, + {/*String|Uri*/ Object? mapUrl}) { + for (var section in sections.cast()) { + var offset = section['offset'] as Map?; + if (offset == null) throw const FormatException('section missing offset'); + + var line = offset['line'] as int?; + if (line == null) throw const FormatException('offset missing line'); + + var column = offset['column'] as int?; + if (column == null) throw const FormatException('offset missing column'); + + _lineStart.add(line); + _columnStart.add(column); + + var url = section['url'] as String?; + var map = section['map'] as Map?; + + if (url != null && map != null) { + throw const FormatException( + "section can't use both url and map entries"); + } else if (url != null) { + var other = otherMaps?[url]; + if (otherMaps == null || other == null) { + throw FormatException( + 'section contains refers to $url, but no map was ' + 'given for it. Make sure a map is passed in "otherMaps"'); + } + _maps.add(parseJson(other, otherMaps: otherMaps, mapUrl: url)); + } else if (map != null) { + _maps.add(parseJson(map, otherMaps: otherMaps, mapUrl: mapUrl)); + } else { + throw const FormatException('section missing url or map'); + } + } + if (_lineStart.isEmpty) { + throw const FormatException('expected at least one section'); + } + } + + int _indexFor(int line, int column) { + for (var i = 0; i < _lineStart.length; i++) { + if (line < _lineStart[i]) return i - 1; + if (line == _lineStart[i] && column < _columnStart[i]) return i - 1; + } + return _lineStart.length - 1; + } + + @override + SourceMapSpan? spanFor(int line, int column, + {Map? files, String? uri}) { + // TODO(jacobr): perhaps verify that targetUrl matches the actual uri + // or at least ends in the same file name. + var index = _indexFor(line, column); + return _maps[index].spanFor( + line - _lineStart[index], column - _columnStart[index], + files: files); + } + + @override + String toString() { + var buff = StringBuffer('$runtimeType : ['); + for (var i = 0; i < _lineStart.length; i++) { + buff + ..write('(') + ..write(_lineStart[i]) + ..write(',') + ..write(_columnStart[i]) + ..write(':') + ..write(_maps[i]) + ..write(')'); + } + buff.write(']'); + return buff.toString(); + } +} + +class MappingBundle extends Mapping { + final Map _mappings = {}; + + MappingBundle(); + + MappingBundle.fromJson(List json, {/*String|Uri*/ Object? mapUrl}) { + for (var map in json) { + addMapping(parseJson(map as Map, mapUrl: mapUrl) as SingleMapping); + } + } + + void addMapping(SingleMapping mapping) { + // TODO(jacobr): verify that targetUrl is valid uri instead of a windows + // path. + // TODO: Remove type arg https://github.com/dart-lang/sdk/issues/42227 + var targetUrl = ArgumentError.checkNotNull( + mapping.targetUrl, 'mapping.targetUrl'); + _mappings[targetUrl] = mapping; + } + + /// Encodes the Mapping mappings as a json map. + List toJson() => _mappings.values.map((v) => v.toJson()).toList(); + + @override + String toString() { + var buff = StringBuffer(); + for (var map in _mappings.values) { + buff.write(map.toString()); + } + return buff.toString(); + } + + bool containsMapping(String url) => _mappings.containsKey(url); + + @override + SourceMapSpan? spanFor(int line, int column, + {Map? files, String? uri}) { + // TODO: Remove type arg https://github.com/dart-lang/sdk/issues/42227 + uri = ArgumentError.checkNotNull(uri, 'uri'); + + // Find the longest suffix of the uri that matches the sourcemap + // where the suffix starts after a path segment boundary. + // We consider ":" and "/" as path segment boundaries so that + // "package:" uris can be handled with minimal special casing. Having a + // few false positive path segment boundaries is not a significant issue + // as we prefer the longest matching prefix. + // Using package:path `path.split` to find path segment boundaries would + // not generate all of the path segment boundaries we want for "package:" + // urls as "package:package_name" would be one path segment when we want + // "package" and "package_name" to be sepearate path segments. + + var onBoundary = true; + var separatorCodeUnits = ['/'.codeUnitAt(0), ':'.codeUnitAt(0)]; + for (var i = 0; i < uri.length; ++i) { + if (onBoundary) { + var candidate = uri.substring(i); + var candidateMapping = _mappings[candidate]; + if (candidateMapping != null) { + return candidateMapping.spanFor(line, column, + files: files, uri: candidate); + } + } + onBoundary = separatorCodeUnits.contains(uri.codeUnitAt(i)); + } + + // Note: when there is no source map for an uri, this behaves like an + // identity function, returning the requested location as the result. + + // Create a mock offset for the output location. We compute it in terms + // of the input line and column to minimize the chances that two different + // line and column locations are mapped to the same offset. + var offset = line * 1000000 + column; + var location = SourceLocation(offset, + line: line, column: column, sourceUrl: Uri.parse(uri)); + return SourceMapSpan(location, location, ''); + } +} + +/// A map containing direct source mappings. +class SingleMapping extends Mapping { + /// Source urls used in the mapping, indexed by id. + final List urls; + + /// Source names used in the mapping, indexed by id. + final List names; + + /// The [SourceFile]s to which the entries in [lines] refer. + /// + /// This is in the same order as [urls]. If this was constructed using + /// [SingleMapping.fromEntries], this contains files from any [FileLocation]s + /// used to build the mapping. If it was parsed from JSON, it contains files + /// for any sources whose contents were provided via the `"sourcesContent"` + /// field. + /// + /// Files whose contents aren't available are `null`. + final List files; + + /// Entries indicating the beginning of each span. + final List lines; + + /// Url of the target file. + String? targetUrl; + + /// Source root prepended to all entries in [urls]. + String? sourceRoot; + + final Uri? _mapUrl; + + final Map extensions; + + SingleMapping._(this.targetUrl, this.files, this.urls, this.names, this.lines) + : _mapUrl = null, + extensions = {}; + + factory SingleMapping.fromEntries(Iterable entries, + [String? fileUrl]) { + // The entries needs to be sorted by the target offsets. + var sourceEntries = entries.toList()..sort(); + var lines = []; + + // Indices associated with file urls that will be part of the source map. We + // rely on map order so that `urls.keys[urls[u]] == u` + var urls = {}; + + // Indices associated with identifiers that will be part of the source map. + // We rely on map order so that `names.keys[names[n]] == n` + var names = {}; + + /// The file for each URL, indexed by [urls]' values. + var files = {}; + + int? lineNum; + late List targetEntries; + for (var sourceEntry in sourceEntries) { + if (lineNum == null || sourceEntry.target.line > lineNum) { + lineNum = sourceEntry.target.line; + targetEntries = []; + lines.add(TargetLineEntry(lineNum, targetEntries)); + } + + var sourceUrl = sourceEntry.source.sourceUrl; + var urlId = urls.putIfAbsent( + sourceUrl == null ? '' : sourceUrl.toString(), () => urls.length); + + if (sourceEntry.source is FileLocation) { + files.putIfAbsent( + urlId, () => (sourceEntry.source as FileLocation).file); + } + + var sourceEntryIdentifierName = sourceEntry.identifierName; + var srcNameId = sourceEntryIdentifierName == null + ? null + : names.putIfAbsent(sourceEntryIdentifierName, () => names.length); + targetEntries.add(TargetEntry(sourceEntry.target.column, urlId, + sourceEntry.source.line, sourceEntry.source.column, srcNameId)); + } + return SingleMapping._(fileUrl, urls.values.map((i) => files[i]).toList(), + urls.keys.toList(), names.keys.toList(), lines); + } + + SingleMapping.fromJson(Map map, {Object? mapUrl}) + : targetUrl = map['file'] as String?, + urls = List.from(map['sources'] as List), + names = List.from((map['names'] as List?) ?? []), + files = List.filled((map['sources'] as List).length, null), + sourceRoot = map['sourceRoot'] as String?, + lines = [], + _mapUrl = mapUrl is String ? Uri.parse(mapUrl) : (mapUrl as Uri?), + extensions = {} { + var sourcesContent = map['sourcesContent'] == null + ? const [] + : List.from(map['sourcesContent'] as List); + for (var i = 0; i < urls.length && i < sourcesContent.length; i++) { + var source = sourcesContent[i]; + if (source == null) continue; + files[i] = SourceFile.fromString(source, url: urls[i]); + } + + var line = 0; + var column = 0; + var srcUrlId = 0; + var srcLine = 0; + var srcColumn = 0; + var srcNameId = 0; + var tokenizer = _MappingTokenizer(map['mappings'] as String); + var entries = []; + + while (tokenizer.hasTokens) { + if (tokenizer.nextKind.isNewLine) { + if (entries.isNotEmpty) { + lines.add(TargetLineEntry(line, entries)); + entries = []; + } + line++; + column = 0; + tokenizer._consumeNewLine(); + continue; + } + + // Decode the next entry, using the previous encountered values to + // decode the relative values. + // + // We expect 1, 4, or 5 values. If present, values are expected in the + // following order: + // 0: the starting column in the current line of the generated file + // 1: the id of the original source file + // 2: the starting line in the original source + // 3: the starting column in the original source + // 4: the id of the original symbol name + // The values are relative to the previous encountered values. + if (tokenizer.nextKind.isNewSegment) throw _segmentError(0, line); + column += tokenizer._consumeValue(); + if (!tokenizer.nextKind.isValue) { + entries.add(TargetEntry(column)); + } else { + srcUrlId += tokenizer._consumeValue(); + if (srcUrlId >= urls.length) { + throw StateError( + 'Invalid source url id. $targetUrl, $line, $srcUrlId'); + } + if (!tokenizer.nextKind.isValue) throw _segmentError(2, line); + srcLine += tokenizer._consumeValue(); + if (!tokenizer.nextKind.isValue) throw _segmentError(3, line); + srcColumn += tokenizer._consumeValue(); + if (!tokenizer.nextKind.isValue) { + entries.add(TargetEntry(column, srcUrlId, srcLine, srcColumn)); + } else { + srcNameId += tokenizer._consumeValue(); + if (srcNameId >= names.length) { + throw StateError('Invalid name id: $targetUrl, $line, $srcNameId'); + } + entries.add( + TargetEntry(column, srcUrlId, srcLine, srcColumn, srcNameId)); + } + } + if (tokenizer.nextKind.isNewSegment) tokenizer._consumeNewSegment(); + } + if (entries.isNotEmpty) { + lines.add(TargetLineEntry(line, entries)); + } + + map.forEach((name, value) { + if (name.startsWith('x_')) extensions[name] = value; + }); + } + + /// Encodes the Mapping mappings as a json map. + /// + /// If [includeSourceContents] is `true`, this includes the source file + /// contents from [files] in the map if possible. + Map toJson({bool includeSourceContents = false}) { + var buff = StringBuffer(); + var line = 0; + var column = 0; + var srcLine = 0; + var srcColumn = 0; + var srcUrlId = 0; + var srcNameId = 0; + var first = true; + + for (var entry in lines) { + var nextLine = entry.line; + if (nextLine > line) { + for (var i = line; i < nextLine; ++i) { + buff.write(';'); + } + line = nextLine; + column = 0; + first = true; + } + + for (var segment in entry.entries) { + if (!first) buff.write(','); + first = false; + column = _append(buff, column, segment.column); + + // Encoding can be just the column offset if there is no source + // information. + var newUrlId = segment.sourceUrlId; + if (newUrlId == null) continue; + srcUrlId = _append(buff, srcUrlId, newUrlId); + srcLine = _append(buff, srcLine, segment.sourceLine!); + srcColumn = _append(buff, srcColumn, segment.sourceColumn!); + + if (segment.sourceNameId == null) continue; + srcNameId = _append(buff, srcNameId, segment.sourceNameId!); + } + } + + var result = { + 'version': 3, + 'sourceRoot': sourceRoot ?? '', + 'sources': urls, + 'names': names, + 'mappings': buff.toString(), + }; + if (targetUrl != null) result['file'] = targetUrl!; + + if (includeSourceContents) { + result['sourcesContent'] = files.map((file) => file?.getText(0)).toList(); + } + extensions.forEach((name, value) => result[name] = value); + + return result; + } + + /// Appends to [buff] a VLQ encoding of [newValue] using the difference + /// between [oldValue] and [newValue] + static int _append(StringBuffer buff, int oldValue, int newValue) { + buff.writeAll(encodeVlq(newValue - oldValue)); + return newValue; + } + + StateError _segmentError(int seen, int line) => + StateError('Invalid entry in sourcemap, expected 1, 4, or 5' + ' values, but got $seen.\ntargeturl: $targetUrl, line: $line'); + + /// Returns [TargetLineEntry] which includes the location in the target [line] + /// number. In particular, the resulting entry is the last entry whose line + /// number is lower or equal to [line]. + TargetLineEntry? _findLine(int line) { + var index = binarySearch(lines, (e) => e.line > line); + return (index <= 0) ? null : lines[index - 1]; + } + + /// Returns [TargetEntry] which includes the location denoted by + /// [line], [column]. If [lineEntry] corresponds to [line], then this will be + /// the last entry whose column is lower or equal than [column]. If + /// [lineEntry] corresponds to a line prior to [line], then the result will be + /// the very last entry on that line. + TargetEntry? _findColumn(int line, int column, TargetLineEntry? lineEntry) { + if (lineEntry == null || lineEntry.entries.isEmpty) return null; + if (lineEntry.line != line) return lineEntry.entries.last; + var entries = lineEntry.entries; + var index = binarySearch(entries, (e) => e.column > column); + return (index <= 0) ? null : entries[index - 1]; + } + + @override + SourceMapSpan? spanFor(int line, int column, + {Map? files, String? uri}) { + var entry = _findColumn(line, column, _findLine(line)); + if (entry == null) return null; + + var sourceUrlId = entry.sourceUrlId; + if (sourceUrlId == null) return null; + + var url = urls[sourceUrlId]; + if (sourceRoot != null) { + url = '$sourceRoot$url'; + } + + var sourceNameId = entry.sourceNameId; + var file = files?[url]; + if (file != null) { + var start = file.getOffset(entry.sourceLine!, entry.sourceColumn); + if (sourceNameId != null) { + var text = names[sourceNameId]; + return SourceMapFileSpan(file.span(start, start + text.length), + isIdentifier: true); + } else { + return SourceMapFileSpan(file.location(start).pointSpan()); + } + } else { + var start = SourceLocation(0, + sourceUrl: _mapUrl?.resolve(url) ?? url, + line: entry.sourceLine, + column: entry.sourceColumn); + + // Offset and other context is not available. + if (sourceNameId != null) { + return SourceMapSpan.identifier(start, names[sourceNameId]); + } else { + return SourceMapSpan(start, start, ''); + } + } + } + + @override + String toString() { + return (StringBuffer('$runtimeType : [') + ..write('targetUrl: ') + ..write(targetUrl) + ..write(', sourceRoot: ') + ..write(sourceRoot) + ..write(', urls: ') + ..write(urls) + ..write(', names: ') + ..write(names) + ..write(', lines: ') + ..write(lines) + ..write(']')) + .toString(); + } + + String get debugString { + var buff = StringBuffer(); + for (var lineEntry in lines) { + var line = lineEntry.line; + for (var entry in lineEntry.entries) { + buff + ..write(targetUrl) + ..write(': ') + ..write(line) + ..write(':') + ..write(entry.column); + var sourceUrlId = entry.sourceUrlId; + if (sourceUrlId != null) { + buff + ..write(' --> ') + ..write(sourceRoot) + ..write(urls[sourceUrlId]) + ..write(': ') + ..write(entry.sourceLine) + ..write(':') + ..write(entry.sourceColumn); + } + var sourceNameId = entry.sourceNameId; + if (sourceNameId != null) { + buff + ..write(' (') + ..write(names[sourceNameId]) + ..write(')'); + } + buff.write('\n'); + } + } + return buff.toString(); + } +} + +/// A line entry read from a source map. +class TargetLineEntry { + final int line; + List entries; + TargetLineEntry(this.line, this.entries); + + @override + String toString() => '$runtimeType: $line $entries'; +} + +/// A target segment entry read from a source map +class TargetEntry { + final int column; + final int? sourceUrlId; + final int? sourceLine; + final int? sourceColumn; + final int? sourceNameId; + + TargetEntry(this.column, + [this.sourceUrlId, + this.sourceLine, + this.sourceColumn, + this.sourceNameId]); + + @override + String toString() => '$runtimeType: ' + '($column, $sourceUrlId, $sourceLine, $sourceColumn, $sourceNameId)'; +} + +/// A character iterator over a string that can peek one character ahead. +class _MappingTokenizer implements Iterator { + final String _internal; + final int _length; + int index = -1; + _MappingTokenizer(String internal) + : _internal = internal, + _length = internal.length; + + // Iterator API is used by decodeVlq to consume VLQ entries. + @override + bool moveNext() => ++index < _length; + + @override + String get current => (index >= 0 && index < _length) + ? _internal[index] + : throw RangeError.index(index, _internal); + + bool get hasTokens => index < _length - 1 && _length > 0; + + _TokenKind get nextKind { + if (!hasTokens) return _TokenKind.eof; + var next = _internal[index + 1]; + if (next == ';') return _TokenKind.line; + if (next == ',') return _TokenKind.segment; + return _TokenKind.value; + } + + int _consumeValue() => decodeVlq(this); + void _consumeNewLine() { + ++index; + } + + void _consumeNewSegment() { + ++index; + } + + // Print the state of the iterator, with colors indicating the current + // position. + @override + String toString() { + var buff = StringBuffer(); + for (var i = 0; i < index; i++) { + buff.write(_internal[i]); + } + buff.write(''); + try { + buff.write(current); + // TODO: Determine whether this try / catch can be removed. + // ignore: avoid_catching_errors + } on RangeError catch (_) {} + buff.write(''); + for (var i = index + 1; i < _internal.length; i++) { + buff.write(_internal[i]); + } + buff.write(' ($index)'); + return buff.toString(); + } +} + +class _TokenKind { + static const _TokenKind line = _TokenKind(isNewLine: true); + static const _TokenKind segment = _TokenKind(isNewSegment: true); + static const _TokenKind eof = _TokenKind(isEof: true); + static const _TokenKind value = _TokenKind(); + final bool isNewLine; + final bool isNewSegment; + final bool isEof; + bool get isValue => !isNewLine && !isNewSegment && !isEof; + + const _TokenKind( + {this.isNewLine = false, this.isNewSegment = false, this.isEof = false}); +} diff --git a/pkgs/source_maps/lib/printer.dart b/pkgs/source_maps/lib/printer.dart new file mode 100644 index 000000000..32523d62f --- /dev/null +++ b/pkgs/source_maps/lib/printer.dart @@ -0,0 +1,262 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Contains a code printer that generates code by recording the source maps. +library; + +import 'package:source_span/source_span.dart'; + +import 'builder.dart'; +import 'src/source_map_span.dart'; +import 'src/utils.dart'; + +/// A simple printer that keeps track of offset locations and records source +/// maps locations. +class Printer { + final String filename; + final StringBuffer _buff = StringBuffer(); + final SourceMapBuilder _maps = SourceMapBuilder(); + String get text => _buff.toString(); + String get map => _maps.toJson(filename); + + /// Current source location mapping. + SourceLocation? _loc; + + /// Current line in the buffer; + int _line = 0; + + /// Current column in the buffer. + int _column = 0; + + Printer(this.filename); + + /// Add [str] contents to the output, tracking new lines to track correct + /// positions for span locations. When [projectMarks] is true, this method + /// adds a source map location on each new line, projecting that every new + /// line in the target file (printed here) corresponds to a new line in the + /// source file. + void add(String str, {bool projectMarks = false}) { + var chars = str.runes.toList(); + var length = chars.length; + for (var i = 0; i < length; i++) { + var c = chars[i]; + if (c == lineFeed || + (c == carriageReturn && + (i + 1 == length || chars[i + 1] != lineFeed))) { + // Return not followed by line-feed is treated as a new line. + _line++; + _column = 0; + { + // **Warning**: Any calls to `mark` will change the value of `_loc`, + // so this local variable is no longer up to date after that point. + // + // This is why it has been put inside its own block to limit the + // scope in which it is available. + var loc = _loc; + if (projectMarks && loc != null) { + if (loc is FileLocation) { + var file = loc.file; + mark(file.location(file.getOffset(loc.line + 1))); + } else { + mark(SourceLocation(0, + sourceUrl: loc.sourceUrl, line: loc.line + 1, column: 0)); + } + } + } + } else { + _column++; + } + } + _buff.write(str); + } + + /// Append a [total] number of spaces in the target file. Typically used for + /// formatting indentation. + void addSpaces(int total) { + for (var i = 0; i < total; i++) { + _buff.write(' '); + } + _column += total; + } + + /// Marks that the current point in the target file corresponds to the [mark] + /// in the source file, which can be either a [SourceLocation] or a + /// [SourceSpan]. When the mark is a [SourceMapSpan] with `isIdentifier` set, + /// this also records the name of the identifier in the source map + /// information. + void mark(Object mark) { + late final SourceLocation loc; + String? identifier; + if (mark is SourceLocation) { + loc = mark; + } else if (mark is SourceSpan) { + loc = mark.start; + if (mark is SourceMapSpan && mark.isIdentifier) identifier = mark.text; + } + _maps.addLocation(loc, + SourceLocation(_buff.length, line: _line, column: _column), identifier); + _loc = loc; + } +} + +/// A more advanced printer that keeps track of offset locations to record +/// source maps, but additionally allows nesting of different kind of items, +/// including [NestedPrinter]s, and it let's you automatically indent text. +/// +/// This class is especially useful when doing code generation, where different +/// pieces of the code are generated independently on separate printers, and are +/// finally put together in the end. +class NestedPrinter implements NestedItem { + /// Items recoded by this printer, which can be [String] literals, + /// [NestedItem]s, and source map information like [SourceLocation] and + /// [SourceSpan]. + final List _items = []; + + /// Internal buffer to merge consecutive strings added to this printer. + StringBuffer? _buff; + + /// Current indentation, which can be updated from outside this class. + int indent; + + /// [Printer] used during the last call to [build], if any. + Printer? printer; + + /// Returns the text produced after calling [build]. + String? get text => printer?.text; + + /// Returns the source-map information produced after calling [build]. + String? get map => printer?.map; + + /// Item used to indicate that the following item is copied from the original + /// source code, and hence we should preserve source-maps on every new line. + static final _original = Object(); + + NestedPrinter([this.indent = 0]); + + /// Adds [object] to this printer. [object] can be a [String], + /// [NestedPrinter], or anything implementing [NestedItem]. If [object] is a + /// [String], the value is appended directly, without doing any formatting + /// changes. If you wish to add a line of code with automatic indentation, use + /// [addLine] instead. [NestedPrinter]s and [NestedItem]s are not processed + /// until [build] gets called later on. We ensure that [build] emits every + /// object in the order that they were added to this printer. + /// + /// The [location] and [span] parameters indicate the corresponding source map + /// location of [object] in the original input. Only one, [location] or + /// [span], should be provided at a time. + /// + /// Indicate [isOriginal] when [object] is copied directly from the user code. + /// Setting [isOriginal] will make this printer propagate source map locations + /// on every line-break. + void add(Object object, + {SourceLocation? location, SourceSpan? span, bool isOriginal = false}) { + if (object is! String || location != null || span != null || isOriginal) { + _flush(); + assert(location == null || span == null); + if (location != null) _items.add(location); + if (span != null) _items.add(span); + if (isOriginal) _items.add(_original); + } + + if (object is String) { + _appendString(object); + } else { + _items.add(object); + } + } + + /// Append `2 * indent` spaces to this printer. + void insertIndent() => _indent(indent); + + /// Add a [line], autoindenting to the current value of [indent]. Note, + /// indentation is not inferred from the contents added to this printer. If a + /// line starts or ends an indentation block, you need to also update [indent] + /// accordingly. Also, indentation is not adapted for nested printers. If + /// you add a [NestedPrinter] to this printer, its indentation is set + /// separately and will not include any the indentation set here. + /// + /// The [location] and [span] parameters indicate the corresponding source map + /// location of [line] in the original input. Only one, [location] or + /// [span], should be provided at a time. + void addLine(String? line, {SourceLocation? location, SourceSpan? span}) { + if (location != null || span != null) { + _flush(); + assert(location == null || span == null); + if (location != null) _items.add(location); + if (span != null) _items.add(span); + } + if (line == null) return; + if (line != '') { + // We don't indent empty lines. + _indent(indent); + _appendString(line); + } + _appendString('\n'); + } + + /// Appends a string merging it with any previous strings, if possible. + void _appendString(String s) { + var buf = _buff ??= StringBuffer(); + buf.write(s); + } + + /// Adds all of the current [_buff] contents as a string item. + void _flush() { + if (_buff != null) { + _items.add(_buff.toString()); + _buff = null; + } + } + + void _indent(int indent) { + for (var i = 0; i < indent; i++) { + _appendString(' '); + } + } + + /// Returns a string representation of all the contents appended to this + /// printer, including source map location tokens. + @override + String toString() { + _flush(); + return (StringBuffer()..writeAll(_items)).toString(); + } + + /// Builds the output of this printer and source map information. After + /// calling this function, you can use [text] and [map] to retrieve the + /// geenrated code and source map information, respectively. + void build(String filename) { + writeTo(printer = Printer(filename)); + } + + /// Implements the [NestedItem] interface. + @override + void writeTo(Printer printer) { + _flush(); + var propagate = false; + for (var item in _items) { + if (item is NestedItem) { + item.writeTo(printer); + } else if (item is String) { + printer.add(item, projectMarks: propagate); + propagate = false; + } else if (item is SourceLocation || item is SourceSpan) { + printer.mark(item); + } else if (item == _original) { + // we insert booleans when we are about to quote text that was copied + // from the original source. In such case, we will propagate marks on + // every new-line. + propagate = true; + } else { + throw UnsupportedError('Unknown item type: $item'); + } + } + } +} + +/// An item added to a [NestedPrinter]. +abstract class NestedItem { + /// Write the contents of this item into [printer]. + void writeTo(Printer printer); +} diff --git a/pkgs/source_maps/lib/refactor.dart b/pkgs/source_maps/lib/refactor.dart new file mode 100644 index 000000000..a518a0ce4 --- /dev/null +++ b/pkgs/source_maps/lib/refactor.dart @@ -0,0 +1,140 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Tools to help implement refactoring like transformations to Dart code. +/// +/// [TextEditTransaction] supports making a series of changes to a text buffer. +/// [guessIndent] helps to guess the appropriate indentiation for the new code. +library; + +import 'package:source_span/source_span.dart'; + +import 'printer.dart'; +import 'src/utils.dart'; + +/// Editable text transaction. +/// +/// Applies a series of edits using original location +/// information, and composes them into the edited string. +class TextEditTransaction { + final SourceFile? file; + final String original; + final _edits = <_TextEdit>[]; + + /// Creates a new transaction. + TextEditTransaction(this.original, this.file); + + bool get hasEdits => _edits.isNotEmpty; + + /// Edit the original text, replacing text on the range [begin] and [end] + /// with the [replacement]. [replacement] can be either a string or a + /// [NestedPrinter]. + void edit(int begin, int end, Object replacement) { + _edits.add(_TextEdit(begin, end, replacement)); + } + + /// Create a source map [SourceLocation] for [offset], if [file] is not + /// `null`. + SourceLocation? _loc(int offset) => file?.location(offset); + + /// Applies all pending [edit]s and returns a [NestedPrinter] containing the + /// rewritten string and source map information. [file]`.location` is given to + /// the underlying printer to indicate the name of the generated file that + /// will contains the source map information. + /// + /// Throws [UnsupportedError] if the edits were overlapping. If no edits were + /// made, the printer simply contains the original string. + NestedPrinter commit() { + var printer = NestedPrinter(); + if (_edits.isEmpty) { + return printer..add(original, location: _loc(0), isOriginal: true); + } + + // Sort edits by start location. + _edits.sort(); + + var consumed = 0; + for (var edit in _edits) { + if (consumed > edit.begin) { + var sb = StringBuffer(); + sb + ..write(file?.location(edit.begin).toolString) + ..write(': overlapping edits. Insert at offset ') + ..write(edit.begin) + ..write(' but have consumed ') + ..write(consumed) + ..write(' input characters. List of edits:'); + for (var e in _edits) { + sb + ..write('\n ') + ..write(e); + } + throw UnsupportedError(sb.toString()); + } + + // Add characters from the original string between this edit and the last + // one, if any. + var betweenEdits = original.substring(consumed, edit.begin); + printer + ..add(betweenEdits, location: _loc(consumed), isOriginal: true) + ..add(edit.replace, location: _loc(edit.begin)); + consumed = edit.end; + } + + // Add any text from the end of the original string that was not replaced. + printer.add(original.substring(consumed), + location: _loc(consumed), isOriginal: true); + return printer; + } +} + +class _TextEdit implements Comparable<_TextEdit> { + final int begin; + final int end; + + /// The replacement used by the edit, can be a string or a [NestedPrinter]. + final Object replace; + + _TextEdit(this.begin, this.end, this.replace); + + int get length => end - begin; + + @override + String toString() => '(Edit @ $begin,$end: "$replace")'; + + @override + int compareTo(_TextEdit other) { + var diff = begin - other.begin; + if (diff != 0) return diff; + return end - other.end; + } +} + +/// Returns all whitespace characters at the start of [charOffset]'s line. +String guessIndent(String code, int charOffset) { + // Find the beginning of the line + var lineStart = 0; + for (var i = charOffset - 1; i >= 0; i--) { + var c = code.codeUnitAt(i); + if (c == lineFeed || c == carriageReturn) { + lineStart = i + 1; + break; + } + } + + // Grab all the whitespace + var whitespaceEnd = code.length; + for (var i = lineStart; i < code.length; i++) { + var c = code.codeUnitAt(i); + if (c != _space && c != _tab) { + whitespaceEnd = i; + break; + } + } + + return code.substring(lineStart, whitespaceEnd); +} + +const int _tab = 9; +const int _space = 32; diff --git a/pkgs/source_maps/lib/source_maps.dart b/pkgs/source_maps/lib/source_maps.dart new file mode 100644 index 000000000..244dee7a9 --- /dev/null +++ b/pkgs/source_maps/lib/source_maps.dart @@ -0,0 +1,38 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Library to create and parse source maps. +/// +/// Create a source map using [SourceMapBuilder]. For example: +/// +/// ```dart +/// var json = (new SourceMapBuilder() +/// ..add(inputSpan1, outputSpan1) +/// ..add(inputSpan2, outputSpan2) +/// ..add(inputSpan3, outputSpan3) +/// .toJson(outputFile); +/// ``` +/// +/// Use the source_span package's [SourceSpan] and [SourceFile] classes to +/// specify span locations. +/// +/// Parse a source map using [parse], and call `spanFor` on the returned mapping +/// object. For example: +/// +/// ```dart +/// var mapping = parse(json); +/// mapping.spanFor(outputSpan1.line, outputSpan1.column) +/// ``` +library; + +import 'package:source_span/source_span.dart'; + +import 'builder.dart'; +import 'parser.dart'; + +export 'builder.dart'; +export 'parser.dart'; +export 'printer.dart'; +export 'refactor.dart'; +export 'src/source_map_span.dart'; diff --git a/pkgs/source_maps/lib/src/source_map_span.dart b/pkgs/source_maps/lib/src/source_map_span.dart new file mode 100644 index 000000000..aad8a32c6 --- /dev/null +++ b/pkgs/source_maps/lib/src/source_map_span.dart @@ -0,0 +1,72 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:source_span/source_span.dart'; + +/// A [SourceSpan] for spans coming from or being written to source maps. +/// +/// These spans have an extra piece of metadata: whether or not they represent +/// an identifier (see [isIdentifier]). +class SourceMapSpan extends SourceSpanBase { + /// Whether this span represents an identifier. + /// + /// If this is `true`, [text] is the value of the identifier. + final bool isIdentifier; + + SourceMapSpan(super.start, super.end, super.text, + {this.isIdentifier = false}); + + /// Creates a [SourceMapSpan] for an identifier with value [text] starting at + /// [start]. + /// + /// The [end] location is determined by adding [text] to [start]. + SourceMapSpan.identifier(SourceLocation start, String text) + : this( + start, + SourceLocation(start.offset + text.length, + sourceUrl: start.sourceUrl, + line: start.line, + column: start.column + text.length), + text, + isIdentifier: true); +} + +/// A wrapper aruond a [FileSpan] that implements [SourceMapSpan]. +class SourceMapFileSpan implements SourceMapSpan, FileSpan { + final FileSpan _inner; + @override + final bool isIdentifier; + + @override + SourceFile get file => _inner.file; + @override + FileLocation get start => _inner.start; + @override + FileLocation get end => _inner.end; + @override + String get text => _inner.text; + @override + String get context => _inner.context; + @override + Uri? get sourceUrl => _inner.sourceUrl; + @override + int get length => _inner.length; + + SourceMapFileSpan(this._inner, {this.isIdentifier = false}); + + @override + int compareTo(SourceSpan other) => _inner.compareTo(other); + @override + String highlight({Object? color}) => _inner.highlight(color: color); + @override + SourceSpan union(SourceSpan other) => _inner.union(other); + @override + FileSpan expand(FileSpan other) => _inner.expand(other); + @override + String message(String message, {Object? color}) => + _inner.message(message, color: color); + @override + String toString() => + _inner.toString().replaceAll('FileSpan', 'SourceMapFileSpan'); +} diff --git a/pkgs/source_maps/lib/src/utils.dart b/pkgs/source_maps/lib/src/utils.dart new file mode 100644 index 000000000..ba04fbb0f --- /dev/null +++ b/pkgs/source_maps/lib/src/utils.dart @@ -0,0 +1,32 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Utilities that shouldn't be in this package. +library; + +/// Find the first entry in a sorted [list] that matches a monotonic predicate. +/// Given a result `n`, that all items before `n` will not match, `n` matches, +/// and all items after `n` match too. The result is -1 when there are no +/// items, 0 when all items match, and list.length when none does. +// TODO(sigmund): remove this function after dartbug.com/5624 is fixed. +int binarySearch(List list, bool Function(T) matches) { + if (list.isEmpty) return -1; + if (matches(list.first)) return 0; + if (!matches(list.last)) return list.length; + + var min = 0; + var max = list.length - 1; + while (min < max) { + var half = min + ((max - min) ~/ 2); + if (matches(list[half])) { + max = half; + } else { + min = half + 1; + } + } + return max; +} + +const int lineFeed = 10; +const int carriageReturn = 13; diff --git a/pkgs/source_maps/lib/src/vlq.dart b/pkgs/source_maps/lib/src/vlq.dart new file mode 100644 index 000000000..3b0562d82 --- /dev/null +++ b/pkgs/source_maps/lib/src/vlq.dart @@ -0,0 +1,101 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Utilities to encode and decode VLQ values used in source maps. +/// +/// Sourcemaps are encoded with variable length numbers as base64 encoded +/// strings with the least significant digit coming first. Each base64 digit +/// encodes a 5-bit value (0-31) and a continuation bit. Signed values can be +/// represented by using the least significant bit of the value as the sign bit. +/// +/// For more details see the source map [version 3 documentation](https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit?usp=sharing). +library; + +import 'dart:math'; + +const int vlqBaseShift = 5; + +const int vlqBaseMask = (1 << 5) - 1; + +const int vlqContinuationBit = 1 << 5; + +const int vlqContinuationMask = 1 << 5; + +const String base64Digits = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +final Map _digits = () { + var map = {}; + for (var i = 0; i < 64; i++) { + map[base64Digits[i]] = i; + } + return map; +}(); + +final int maxInt32 = (pow(2, 31) as int) - 1; +final int minInt32 = -(pow(2, 31) as int); + +/// Creates the VLQ encoding of [value] as a sequence of characters +Iterable encodeVlq(int value) { + if (value < minInt32 || value > maxInt32) { + throw ArgumentError('expected 32 bit int, got: $value'); + } + var res = []; + var signBit = 0; + if (value < 0) { + signBit = 1; + value = -value; + } + value = (value << 1) | signBit; + do { + var digit = value & vlqBaseMask; + value >>= vlqBaseShift; + if (value > 0) { + digit |= vlqContinuationBit; + } + res.add(base64Digits[digit]); + } while (value > 0); + return res; +} + +/// Decodes a value written as a sequence of VLQ characters. The first input +/// character will be `chars.current` after calling `chars.moveNext` once. The +/// iterator is advanced until a stop character is found (a character without +/// the [vlqContinuationBit]). +int decodeVlq(Iterator chars) { + var result = 0; + var stop = false; + var shift = 0; + while (!stop) { + if (!chars.moveNext()) throw StateError('incomplete VLQ value'); + var char = chars.current; + var digit = _digits[char]; + if (digit == null) { + throw FormatException('invalid character in VLQ encoding: $char'); + } + stop = (digit & vlqContinuationBit) == 0; + digit &= vlqBaseMask; + result += digit << shift; + shift += vlqBaseShift; + } + + // Result uses the least significant bit as a sign bit. We convert it into a + // two-complement value. For example, + // 2 (10 binary) becomes 1 + // 3 (11 binary) becomes -1 + // 4 (100 binary) becomes 2 + // 5 (101 binary) becomes -2 + // 6 (110 binary) becomes 3 + // 7 (111 binary) becomes -3 + var negate = (result & 1) == 1; + result = result >> 1; + result = negate ? -result : result; + + // TODO(sigmund): can we detect this earlier? + if (result < minInt32 || result > maxInt32) { + throw FormatException( + 'expected an encoded 32 bit int, but we got: $result'); + } + return result; +} diff --git a/pkgs/source_maps/pubspec.yaml b/pkgs/source_maps/pubspec.yaml new file mode 100644 index 000000000..32cbf4fb7 --- /dev/null +++ b/pkgs/source_maps/pubspec.yaml @@ -0,0 +1,15 @@ +name: source_maps +version: 0.10.14-wip +description: A library to programmatically manipulate source map files. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/source_maps + +environment: + sdk: ^3.3.0 + +dependencies: + source_span: ^1.8.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + term_glyph: ^1.2.0 + test: ^1.16.0 diff --git a/pkgs/source_maps/test/builder_test.dart b/pkgs/source_maps/test/builder_test.dart new file mode 100644 index 000000000..4f773e773 --- /dev/null +++ b/pkgs/source_maps/test/builder_test.dart @@ -0,0 +1,32 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:source_maps/source_maps.dart'; +import 'package:test/test.dart'; + +import 'common.dart'; + +void main() { + test('builder - with span', () { + var map = (SourceMapBuilder() + ..addSpan(inputVar1, outputVar1) + ..addSpan(inputFunction, outputFunction) + ..addSpan(inputVar2, outputVar2) + ..addSpan(inputExpr, outputExpr)) + .build(output.url.toString()); + expect(map, equals(expectedMap)); + }); + + test('builder - with location', () { + var str = (SourceMapBuilder() + ..addLocation(inputVar1.start, outputVar1.start, 'longVar1') + ..addLocation(inputFunction.start, outputFunction.start, 'longName') + ..addLocation(inputVar2.start, outputVar2.start, 'longVar2') + ..addLocation(inputExpr.start, outputExpr.start, null)) + .toJson(output.url.toString()); + expect(str, jsonEncode(expectedMap)); + }); +} diff --git a/pkgs/source_maps/test/common.dart b/pkgs/source_maps/test/common.dart new file mode 100644 index 000000000..e225ff5a4 --- /dev/null +++ b/pkgs/source_maps/test/common.dart @@ -0,0 +1,107 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Common input/output used by builder, parser and end2end tests +library; + +import 'package:source_maps/source_maps.dart'; +import 'package:source_span/source_span.dart'; +import 'package:test/test.dart'; + +/// Content of the source file +const String inputContent = ''' +/** this is a comment. */ +int longVar1 = 3; + +// this is a comment too +int longName(int longVar2) { + return longVar1 + longVar2; +} +'''; +final input = SourceFile.fromString(inputContent, url: 'input.dart'); + +/// A span in the input file +SourceMapSpan ispan(int start, int end, [bool isIdentifier = false]) => + SourceMapFileSpan(input.span(start, end), isIdentifier: isIdentifier); + +SourceMapSpan inputVar1 = ispan(30, 38, true); +SourceMapSpan inputFunction = ispan(74, 82, true); +SourceMapSpan inputVar2 = ispan(87, 95, true); + +SourceMapSpan inputVar1NoSymbol = ispan(30, 38); +SourceMapSpan inputFunctionNoSymbol = ispan(74, 82); +SourceMapSpan inputVar2NoSymbol = ispan(87, 95); + +SourceMapSpan inputExpr = ispan(108, 127); + +/// Content of the target file +const String outputContent = ''' +var x = 3; +f(y) => x + y; +'''; +final output = SourceFile.fromString(outputContent, url: 'output.dart'); + +/// A span in the output file +SourceMapSpan ospan(int start, int end, [bool isIdentifier = false]) => + SourceMapFileSpan(output.span(start, end), isIdentifier: isIdentifier); + +SourceMapSpan outputVar1 = ospan(4, 5, true); +SourceMapSpan outputFunction = ospan(11, 12, true); +SourceMapSpan outputVar2 = ospan(13, 14, true); +SourceMapSpan outputVar1NoSymbol = ospan(4, 5); +SourceMapSpan outputFunctionNoSymbol = ospan(11, 12); +SourceMapSpan outputVar2NoSymbol = ospan(13, 14); +SourceMapSpan outputExpr = ospan(19, 24); + +/// Expected output mapping when recording the following four mappings: +/// inputVar1 <= outputVar1 +/// inputFunction <= outputFunction +/// inputVar2 <= outputVar2 +/// inputExpr <= outputExpr +/// +/// This mapping is stored in the tests so we can independently test the builder +/// and parser algorithms without relying entirely on end2end tests. +const Map expectedMap = { + 'version': 3, + 'sourceRoot': '', + 'sources': ['input.dart'], + 'names': ['longVar1', 'longName', 'longVar2'], + 'mappings': 'IACIA;AAGAC,EAAaC,MACR', + 'file': 'output.dart' +}; + +void check(SourceSpan outputSpan, Mapping mapping, SourceMapSpan inputSpan, + bool realOffsets) { + var line = outputSpan.start.line; + var column = outputSpan.start.column; + var files = realOffsets ? {'input.dart': input} : null; + var span = mapping.spanFor(line, column, files: files)!; + var span2 = mapping.spanForLocation(outputSpan.start, files: files)!; + + // Both mapping APIs are equivalent. + expect(span.start.offset, span2.start.offset); + expect(span.start.line, span2.start.line); + expect(span.start.column, span2.start.column); + expect(span.end.offset, span2.end.offset); + expect(span.end.line, span2.end.line); + expect(span.end.column, span2.end.column); + + // Mapping matches our input location (modulo using real offsets) + expect(span.start.line, inputSpan.start.line); + expect(span.start.column, inputSpan.start.column); + expect(span.sourceUrl, inputSpan.sourceUrl); + expect(span.start.offset, realOffsets ? inputSpan.start.offset : 0); + + // Mapping includes the identifier, if any + if (inputSpan.isIdentifier) { + expect(span.end.line, inputSpan.end.line); + expect(span.end.column, inputSpan.end.column); + expect(span.end.offset, span.start.offset + inputSpan.text.length); + if (realOffsets) expect(span.end.offset, inputSpan.end.offset); + } else { + expect(span.end.offset, span.start.offset); + expect(span.end.line, span.start.line); + expect(span.end.column, span.start.column); + } +} diff --git a/pkgs/source_maps/test/end2end_test.dart b/pkgs/source_maps/test/end2end_test.dart new file mode 100644 index 000000000..84dd5badc --- /dev/null +++ b/pkgs/source_maps/test/end2end_test.dart @@ -0,0 +1,160 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:source_maps/source_maps.dart'; +import 'package:source_span/source_span.dart'; +import 'package:test/test.dart'; + +import 'common.dart'; + +void main() { + test('end-to-end setup', () { + expect(inputVar1.text, 'longVar1'); + expect(inputFunction.text, 'longName'); + expect(inputVar2.text, 'longVar2'); + expect(inputVar1NoSymbol.text, 'longVar1'); + expect(inputFunctionNoSymbol.text, 'longName'); + expect(inputVar2NoSymbol.text, 'longVar2'); + expect(inputExpr.text, 'longVar1 + longVar2'); + + expect(outputVar1.text, 'x'); + expect(outputFunction.text, 'f'); + expect(outputVar2.text, 'y'); + expect(outputVar1NoSymbol.text, 'x'); + expect(outputFunctionNoSymbol.text, 'f'); + expect(outputVar2NoSymbol.text, 'y'); + expect(outputExpr.text, 'x + y'); + }); + + test('build + parse', () { + var map = (SourceMapBuilder() + ..addSpan(inputVar1, outputVar1) + ..addSpan(inputFunction, outputFunction) + ..addSpan(inputVar2, outputVar2) + ..addSpan(inputExpr, outputExpr)) + .build(output.url.toString()); + var mapping = parseJson(map); + check(outputVar1, mapping, inputVar1, false); + check(outputVar2, mapping, inputVar2, false); + check(outputFunction, mapping, inputFunction, false); + check(outputExpr, mapping, inputExpr, false); + }); + + test('build + parse - no symbols', () { + var map = (SourceMapBuilder() + ..addSpan(inputVar1NoSymbol, outputVar1NoSymbol) + ..addSpan(inputFunctionNoSymbol, outputFunctionNoSymbol) + ..addSpan(inputVar2NoSymbol, outputVar2NoSymbol) + ..addSpan(inputExpr, outputExpr)) + .build(output.url.toString()); + var mapping = parseJson(map); + check(outputVar1NoSymbol, mapping, inputVar1NoSymbol, false); + check(outputVar2NoSymbol, mapping, inputVar2NoSymbol, false); + check(outputFunctionNoSymbol, mapping, inputFunctionNoSymbol, false); + check(outputExpr, mapping, inputExpr, false); + }); + + test('build + parse, repeated entries', () { + var map = (SourceMapBuilder() + ..addSpan(inputVar1, outputVar1) + ..addSpan(inputVar1, outputVar1) + ..addSpan(inputFunction, outputFunction) + ..addSpan(inputFunction, outputFunction) + ..addSpan(inputVar2, outputVar2) + ..addSpan(inputVar2, outputVar2) + ..addSpan(inputExpr, outputExpr) + ..addSpan(inputExpr, outputExpr)) + .build(output.url.toString()); + var mapping = parseJson(map); + check(outputVar1, mapping, inputVar1, false); + check(outputVar2, mapping, inputVar2, false); + check(outputFunction, mapping, inputFunction, false); + check(outputExpr, mapping, inputExpr, false); + }); + + test('build + parse - no symbols, repeated entries', () { + var map = (SourceMapBuilder() + ..addSpan(inputVar1NoSymbol, outputVar1NoSymbol) + ..addSpan(inputVar1NoSymbol, outputVar1NoSymbol) + ..addSpan(inputFunctionNoSymbol, outputFunctionNoSymbol) + ..addSpan(inputFunctionNoSymbol, outputFunctionNoSymbol) + ..addSpan(inputVar2NoSymbol, outputVar2NoSymbol) + ..addSpan(inputVar2NoSymbol, outputVar2NoSymbol) + ..addSpan(inputExpr, outputExpr)) + .build(output.url.toString()); + var mapping = parseJson(map); + check(outputVar1NoSymbol, mapping, inputVar1NoSymbol, false); + check(outputVar2NoSymbol, mapping, inputVar2NoSymbol, false); + check(outputFunctionNoSymbol, mapping, inputFunctionNoSymbol, false); + check(outputExpr, mapping, inputExpr, false); + }); + + test('build + parse with file', () { + var json = (SourceMapBuilder() + ..addSpan(inputVar1, outputVar1) + ..addSpan(inputFunction, outputFunction) + ..addSpan(inputVar2, outputVar2) + ..addSpan(inputExpr, outputExpr)) + .toJson(output.url.toString()); + var mapping = parse(json); + check(outputVar1, mapping, inputVar1, true); + check(outputVar2, mapping, inputVar2, true); + check(outputFunction, mapping, inputFunction, true); + check(outputExpr, mapping, inputExpr, true); + }); + + test('printer projecting marks + parse', () { + var out = inputContent.replaceAll('long', '_s'); + var file = SourceFile.fromString(out, url: 'output2.dart'); + var printer = Printer('output2.dart'); + printer.mark(ispan(0, 0)); + + var segments = inputContent.split('long'); + expect(segments.length, 6); + printer.add(segments[0], projectMarks: true); + printer.mark(inputVar1); + printer.add('_s'); + printer.add(segments[1], projectMarks: true); + printer.mark(inputFunction); + printer.add('_s'); + printer.add(segments[2], projectMarks: true); + printer.mark(inputVar2); + printer.add('_s'); + printer.add(segments[3], projectMarks: true); + printer.mark(inputExpr); + printer.add('_s'); + printer.add(segments[4], projectMarks: true); + printer.add('_s'); + printer.add(segments[5], projectMarks: true); + + expect(printer.text, out); + + var mapping = parse(printer.map); + void checkHelper(SourceMapSpan inputSpan, int adjustment) { + var start = inputSpan.start.offset - adjustment; + var end = (inputSpan.end.offset - adjustment) - 2; + var span = SourceMapFileSpan(file.span(start, end), + isIdentifier: inputSpan.isIdentifier); + check(span, mapping, inputSpan, true); + } + + checkHelper(inputVar1, 0); + checkHelper(inputFunction, 2); + checkHelper(inputVar2, 4); + checkHelper(inputExpr, 6); + + // We projected correctly lines that have no mappings + check(file.span(66, 66), mapping, ispan(45, 45), true); + check(file.span(63, 64), mapping, ispan(45, 45), true); + check(file.span(68, 68), mapping, ispan(70, 70), true); + check(file.span(71, 71), mapping, ispan(70, 70), true); + + // Start of the last line + var oOffset = out.length - 2; + var iOffset = inputContent.length - 2; + check(file.span(oOffset, oOffset), mapping, ispan(iOffset, iOffset), true); + check(file.span(oOffset + 1, oOffset + 1), mapping, ispan(iOffset, iOffset), + true); + }); +} diff --git a/pkgs/source_maps/test/parser_test.dart b/pkgs/source_maps/test/parser_test.dart new file mode 100644 index 000000000..6cfe928f2 --- /dev/null +++ b/pkgs/source_maps/test/parser_test.dart @@ -0,0 +1,431 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// ignore_for_file: inference_failure_on_collection_literal +// ignore_for_file: inference_failure_on_instance_creation + +import 'dart:convert'; + +import 'package:source_maps/source_maps.dart'; +import 'package:source_span/source_span.dart'; +import 'package:test/test.dart'; + +import 'common.dart'; + +const Map _mapWithNoSourceLocation = { + 'version': 3, + 'sourceRoot': '', + 'sources': ['input.dart'], + 'names': [], + 'mappings': 'A', + 'file': 'output.dart' +}; + +const Map _mapWithSourceLocation = { + 'version': 3, + 'sourceRoot': '', + 'sources': ['input.dart'], + 'names': [], + 'mappings': 'AAAA', + 'file': 'output.dart' +}; + +const Map _mapWithSourceLocationAndMissingNames = { + 'version': 3, + 'sourceRoot': '', + 'sources': ['input.dart'], + 'mappings': 'AAAA', + 'file': 'output.dart' +}; + +const Map _mapWithSourceLocationAndName = { + 'version': 3, + 'sourceRoot': '', + 'sources': ['input.dart'], + 'names': ['var'], + 'mappings': 'AAAAA', + 'file': 'output.dart' +}; + +const Map _mapWithSourceLocationAndName1 = { + 'version': 3, + 'sourceRoot': 'pkg/', + 'sources': ['input1.dart'], + 'names': ['var1'], + 'mappings': 'AAAAA', + 'file': 'output.dart' +}; + +const Map _mapWithSourceLocationAndName2 = { + 'version': 3, + 'sourceRoot': 'pkg/', + 'sources': ['input2.dart'], + 'names': ['var2'], + 'mappings': 'AAAAA', + 'file': 'output2.dart' +}; + +const Map _mapWithSourceLocationAndName3 = { + 'version': 3, + 'sourceRoot': 'pkg/', + 'sources': ['input3.dart'], + 'names': ['var3'], + 'mappings': 'AAAAA', + 'file': '3/output.dart' +}; + +const _sourceMapBundle = [ + _mapWithSourceLocationAndName1, + _mapWithSourceLocationAndName2, + _mapWithSourceLocationAndName3, +]; + +void main() { + test('parse', () { + var mapping = parseJson(expectedMap); + check(outputVar1, mapping, inputVar1, false); + check(outputVar2, mapping, inputVar2, false); + check(outputFunction, mapping, inputFunction, false); + check(outputExpr, mapping, inputExpr, false); + }); + + test('parse + json', () { + var mapping = parse(jsonEncode(expectedMap)); + check(outputVar1, mapping, inputVar1, false); + check(outputVar2, mapping, inputVar2, false); + check(outputFunction, mapping, inputFunction, false); + check(outputExpr, mapping, inputExpr, false); + }); + + test('parse with file', () { + var mapping = parseJson(expectedMap); + check(outputVar1, mapping, inputVar1, true); + check(outputVar2, mapping, inputVar2, true); + check(outputFunction, mapping, inputFunction, true); + check(outputExpr, mapping, inputExpr, true); + }); + + test('parse with no source location', () { + var map = parse(jsonEncode(_mapWithNoSourceLocation)) as SingleMapping; + expect(map.lines.length, 1); + expect(map.lines.first.entries.length, 1); + var entry = map.lines.first.entries.first; + + expect(entry.column, 0); + expect(entry.sourceUrlId, null); + expect(entry.sourceColumn, null); + expect(entry.sourceLine, null); + expect(entry.sourceNameId, null); + }); + + test('parse with source location and no name', () { + var map = parse(jsonEncode(_mapWithSourceLocation)) as SingleMapping; + expect(map.lines.length, 1); + expect(map.lines.first.entries.length, 1); + var entry = map.lines.first.entries.first; + + expect(entry.column, 0); + expect(entry.sourceUrlId, 0); + expect(entry.sourceColumn, 0); + expect(entry.sourceLine, 0); + expect(entry.sourceNameId, null); + }); + + test('parse with source location and missing names entry', () { + var map = parse(jsonEncode(_mapWithSourceLocationAndMissingNames)) + as SingleMapping; + expect(map.lines.length, 1); + expect(map.lines.first.entries.length, 1); + var entry = map.lines.first.entries.first; + + expect(entry.column, 0); + expect(entry.sourceUrlId, 0); + expect(entry.sourceColumn, 0); + expect(entry.sourceLine, 0); + expect(entry.sourceNameId, null); + }); + + test('parse with source location and name', () { + var map = parse(jsonEncode(_mapWithSourceLocationAndName)) as SingleMapping; + expect(map.lines.length, 1); + expect(map.lines.first.entries.length, 1); + var entry = map.lines.first.entries.first; + + expect(entry.sourceUrlId, 0); + expect(entry.sourceUrlId, 0); + expect(entry.sourceColumn, 0); + expect(entry.sourceLine, 0); + expect(entry.sourceNameId, 0); + }); + + test('parse with source root', () { + var inputMap = Map.from(_mapWithSourceLocation); + inputMap['sourceRoot'] = '/pkg/'; + var mapping = parseJson(inputMap) as SingleMapping; + expect(mapping.spanFor(0, 0)?.sourceUrl, Uri.parse('/pkg/input.dart')); + expect( + mapping + .spanForLocation( + SourceLocation(0, sourceUrl: Uri.parse('ignored.dart'))) + ?.sourceUrl, + Uri.parse('/pkg/input.dart')); + + var newSourceRoot = '/new/'; + + mapping.sourceRoot = newSourceRoot; + inputMap['sourceRoot'] = newSourceRoot; + + expect(mapping.toJson(), equals(inputMap)); + }); + + test('parse with map URL', () { + var inputMap = Map.from(_mapWithSourceLocation); + inputMap['sourceRoot'] = 'pkg/'; + var mapping = parseJson(inputMap, mapUrl: 'file:///path/to/map'); + expect(mapping.spanFor(0, 0)?.sourceUrl, + Uri.parse('file:///path/to/pkg/input.dart')); + }); + + group('parse with bundle', () { + var mapping = + parseJsonExtended(_sourceMapBundle, mapUrl: 'file:///path/to/map'); + + test('simple', () { + expect( + mapping + .spanForLocation(SourceLocation(0, + sourceUrl: Uri.file('/path/to/output.dart'))) + ?.sourceUrl, + Uri.parse('file:///path/to/pkg/input1.dart')); + expect( + mapping + .spanForLocation(SourceLocation(0, + sourceUrl: Uri.file('/path/to/output2.dart'))) + ?.sourceUrl, + Uri.parse('file:///path/to/pkg/input2.dart')); + expect( + mapping + .spanForLocation(SourceLocation(0, + sourceUrl: Uri.file('/path/to/3/output.dart'))) + ?.sourceUrl, + Uri.parse('file:///path/to/pkg/input3.dart')); + + expect( + mapping.spanFor(0, 0, uri: 'file:///path/to/output.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input1.dart')); + expect( + mapping.spanFor(0, 0, uri: 'file:///path/to/output2.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input2.dart')); + expect( + mapping + .spanFor(0, 0, uri: 'file:///path/to/3/output.dart') + ?.sourceUrl, + Uri.parse('file:///path/to/pkg/input3.dart')); + }); + + test('package uris', () { + expect( + mapping + .spanForLocation(SourceLocation(0, + sourceUrl: Uri.parse('package:1/output.dart'))) + ?.sourceUrl, + Uri.parse('file:///path/to/pkg/input1.dart')); + expect( + mapping + .spanForLocation(SourceLocation(0, + sourceUrl: Uri.parse('package:2/output2.dart'))) + ?.sourceUrl, + Uri.parse('file:///path/to/pkg/input2.dart')); + expect( + mapping + .spanForLocation(SourceLocation(0, + sourceUrl: Uri.parse('package:3/output.dart'))) + ?.sourceUrl, + Uri.parse('file:///path/to/pkg/input3.dart')); + + expect(mapping.spanFor(0, 0, uri: 'package:1/output.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input1.dart')); + expect(mapping.spanFor(0, 0, uri: 'package:2/output2.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input2.dart')); + expect(mapping.spanFor(0, 0, uri: 'package:3/output.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input3.dart')); + }); + + test('unmapped path', () { + var span = mapping.spanFor(0, 0, uri: 'unmapped_output.dart')!; + expect(span.sourceUrl, Uri.parse('unmapped_output.dart')); + expect(span.start.line, equals(0)); + expect(span.start.column, equals(0)); + + span = mapping.spanFor(10, 5, uri: 'unmapped_output.dart')!; + expect(span.sourceUrl, Uri.parse('unmapped_output.dart')); + expect(span.start.line, equals(10)); + expect(span.start.column, equals(5)); + }); + + test('missing path', () { + expect(() => mapping.spanFor(0, 0), throwsA(anything)); + }); + + test('incomplete paths', () { + expect(mapping.spanFor(0, 0, uri: 'output.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input1.dart')); + expect(mapping.spanFor(0, 0, uri: 'output2.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input2.dart')); + expect(mapping.spanFor(0, 0, uri: '3/output.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input3.dart')); + }); + + test('parseExtended', () { + var mapping = parseExtended(jsonEncode(_sourceMapBundle), + mapUrl: 'file:///path/to/map'); + + expect(mapping.spanFor(0, 0, uri: 'output.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input1.dart')); + expect(mapping.spanFor(0, 0, uri: 'output2.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input2.dart')); + expect(mapping.spanFor(0, 0, uri: '3/output.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input3.dart')); + }); + + test('build bundle incrementally', () { + var mapping = MappingBundle(); + + mapping.addMapping(parseJson(_mapWithSourceLocationAndName1, + mapUrl: 'file:///path/to/map') as SingleMapping); + expect(mapping.spanFor(0, 0, uri: 'output.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input1.dart')); + + expect(mapping.containsMapping('output2.dart'), isFalse); + mapping.addMapping(parseJson(_mapWithSourceLocationAndName2, + mapUrl: 'file:///path/to/map') as SingleMapping); + expect(mapping.containsMapping('output2.dart'), isTrue); + expect(mapping.spanFor(0, 0, uri: 'output2.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input2.dart')); + + expect(mapping.containsMapping('3/output.dart'), isFalse); + mapping.addMapping(parseJson(_mapWithSourceLocationAndName3, + mapUrl: 'file:///path/to/map') as SingleMapping); + expect(mapping.containsMapping('3/output.dart'), isTrue); + expect(mapping.spanFor(0, 0, uri: '3/output.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input3.dart')); + }); + + // Test that the source map can handle cases where the uri passed in is + // not from the expected host but it is still unambiguous which source + // map should be used. + test('different paths', () { + expect( + mapping + .spanForLocation(SourceLocation(0, + sourceUrl: Uri.parse('http://localhost/output.dart'))) + ?.sourceUrl, + Uri.parse('file:///path/to/pkg/input1.dart')); + expect( + mapping + .spanForLocation(SourceLocation(0, + sourceUrl: Uri.parse('http://localhost/output2.dart'))) + ?.sourceUrl, + Uri.parse('file:///path/to/pkg/input2.dart')); + expect( + mapping + .spanForLocation(SourceLocation(0, + sourceUrl: Uri.parse('http://localhost/3/output.dart'))) + ?.sourceUrl, + Uri.parse('file:///path/to/pkg/input3.dart')); + + expect( + mapping.spanFor(0, 0, uri: 'http://localhost/output.dart')?.sourceUrl, + Uri.parse('file:///path/to/pkg/input1.dart')); + expect( + mapping + .spanFor(0, 0, uri: 'http://localhost/output2.dart') + ?.sourceUrl, + Uri.parse('file:///path/to/pkg/input2.dart')); + expect( + mapping + .spanFor(0, 0, uri: 'http://localhost/3/output.dart') + ?.sourceUrl, + Uri.parse('file:///path/to/pkg/input3.dart')); + }); + }); + + test('parse and re-emit', () { + for (var expected in [ + expectedMap, + _mapWithNoSourceLocation, + _mapWithSourceLocation, + _mapWithSourceLocationAndName + ]) { + var mapping = parseJson(expected) as SingleMapping; + expect(mapping.toJson(), equals(expected)); + + mapping = parseJsonExtended(expected) as SingleMapping; + expect(mapping.toJson(), equals(expected)); + } + + var mapping = parseJsonExtended(_sourceMapBundle) as MappingBundle; + expect(mapping.toJson(), equals(_sourceMapBundle)); + }); + + test('parse extensions', () { + var map = Map.from(expectedMap); + map['x_foo'] = 'a'; + map['x_bar'] = [3]; + var mapping = parseJson(map) as SingleMapping; + expect(mapping.toJson(), equals(map)); + expect(mapping.extensions['x_foo'], equals('a')); + expect((mapping.extensions['x_bar'] as List).first, equals(3)); + }); + + group('source files', () { + group('from fromEntries()', () { + test('are null for non-FileLocations', () { + var mapping = SingleMapping.fromEntries([ + Entry(SourceLocation(10, line: 1, column: 8), outputVar1.start, null) + ]); + expect(mapping.files, equals([null])); + }); + + test("use a file location's file", () { + var mapping = SingleMapping.fromEntries( + [Entry(inputVar1.start, outputVar1.start, null)]); + expect(mapping.files, equals([input])); + }); + }); + + group('from parse()', () { + group('are null', () { + test('with no sourcesContent field', () { + var mapping = parseJson(expectedMap) as SingleMapping; + expect(mapping.files, equals([null])); + }); + + test('with null sourcesContent values', () { + var map = Map.from(expectedMap); + map['sourcesContent'] = [null]; + var mapping = parseJson(map) as SingleMapping; + expect(mapping.files, equals([null])); + }); + + test('with a too-short sourcesContent', () { + var map = Map.from(expectedMap); + map['sourcesContent'] = []; + var mapping = parseJson(map) as SingleMapping; + expect(mapping.files, equals([null])); + }); + }); + + test('are parsed from sourcesContent', () { + var map = Map.from(expectedMap); + map['sourcesContent'] = ['hello, world!']; + var mapping = parseJson(map) as SingleMapping; + + var file = mapping.files[0]!; + expect(file.url, equals(Uri.parse('input.dart'))); + expect(file.getText(0), equals('hello, world!')); + }); + }); + }); +} diff --git a/pkgs/source_maps/test/printer_test.dart b/pkgs/source_maps/test/printer_test.dart new file mode 100644 index 000000000..89265e36b --- /dev/null +++ b/pkgs/source_maps/test/printer_test.dart @@ -0,0 +1,126 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:source_maps/source_maps.dart'; +import 'package:source_span/source_span.dart'; +import 'package:test/test.dart'; + +import 'common.dart'; + +void main() { + test('printer', () { + var printer = Printer('output.dart'); + printer + ..add('var ') + ..mark(inputVar1) + ..add('x = 3;\n') + ..mark(inputFunction) + ..add('f(') + ..mark(inputVar2) + ..add('y) => ') + ..mark(inputExpr) + ..add('x + y;\n'); + expect(printer.text, outputContent); + expect(printer.map, jsonEncode(expectedMap)); + }); + + test('printer projecting marks', () { + var out = inputContent.replaceAll('long', '_s'); + var printer = Printer('output2.dart'); + + var segments = inputContent.split('long'); + expect(segments.length, 6); + printer + ..mark(ispan(0, 0)) + ..add(segments[0], projectMarks: true) + ..mark(inputVar1) + ..add('_s') + ..add(segments[1], projectMarks: true) + ..mark(inputFunction) + ..add('_s') + ..add(segments[2], projectMarks: true) + ..mark(inputVar2) + ..add('_s') + ..add(segments[3], projectMarks: true) + ..mark(inputExpr) + ..add('_s') + ..add(segments[4], projectMarks: true) + ..add('_s') + ..add(segments[5], projectMarks: true); + + expect(printer.text, out); + // 8 new lines in the source map: + expect(printer.map.split(';').length, 8); + + SourceMapSpan asFixed(SourceMapSpan s) => + SourceMapSpan(s.start, s.end, s.text, isIdentifier: s.isIdentifier); + + // The result is the same if we use fixed positions + var printer2 = Printer('output2.dart'); + printer2 + ..mark(SourceLocation(0, sourceUrl: 'input.dart').pointSpan()) + ..add(segments[0], projectMarks: true) + ..mark(asFixed(inputVar1)) + ..add('_s') + ..add(segments[1], projectMarks: true) + ..mark(asFixed(inputFunction)) + ..add('_s') + ..add(segments[2], projectMarks: true) + ..mark(asFixed(inputVar2)) + ..add('_s') + ..add(segments[3], projectMarks: true) + ..mark(asFixed(inputExpr)) + ..add('_s') + ..add(segments[4], projectMarks: true) + ..add('_s') + ..add(segments[5], projectMarks: true); + + expect(printer2.text, out); + expect(printer2.map, printer.map); + }); + + group('nested printer', () { + test('simple use', () { + var printer = NestedPrinter(); + printer + ..add('var ') + ..add('x = 3;\n', span: inputVar1) + ..add('f(', span: inputFunction) + ..add('y) => ', span: inputVar2) + ..add('x + y;\n', span: inputExpr) + ..build('output.dart'); + expect(printer.text, outputContent); + expect(printer.map, jsonEncode(expectedMap)); + }); + + test('nested use', () { + var printer = NestedPrinter(); + printer + ..add('var ') + ..add(NestedPrinter()..add('x = 3;\n', span: inputVar1)) + ..add('f(', span: inputFunction) + ..add(NestedPrinter()..add('y) => ', span: inputVar2)) + ..add('x + y;\n', span: inputExpr) + ..build('output.dart'); + expect(printer.text, outputContent); + expect(printer.map, jsonEncode(expectedMap)); + }); + + test('add indentation', () { + var out = inputContent.replaceAll('long', '_s'); + var lines = inputContent.trim().split('\n'); + expect(lines.length, 7); + var printer = NestedPrinter(); + for (var i = 0; i < lines.length; i++) { + if (i == 5) printer.indent++; + printer.addLine(lines[i].replaceAll('long', '_s').trim()); + if (i == 5) printer.indent--; + } + printer.build('output.dart'); + expect(printer.text, out); + }); + }); +} diff --git a/pkgs/source_maps/test/refactor_test.dart b/pkgs/source_maps/test/refactor_test.dart new file mode 100644 index 000000000..5bc3818e5 --- /dev/null +++ b/pkgs/source_maps/test/refactor_test.dart @@ -0,0 +1,199 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:source_maps/parser.dart' show Mapping, parse; +import 'package:source_maps/refactor.dart'; +import 'package:source_span/source_span.dart'; +import 'package:term_glyph/term_glyph.dart' as term_glyph; +import 'package:test/test.dart'; + +void main() { + setUpAll(() { + term_glyph.ascii = true; + }); + + group('conflict detection', () { + var original = '0123456789abcdefghij'; + var file = SourceFile.fromString(original); + + test('no conflict, in order', () { + var txn = TextEditTransaction(original, file); + txn.edit(2, 4, '.'); + txn.edit(5, 5, '|'); + txn.edit(6, 6, '-'); + txn.edit(6, 7, '_'); + expect((txn.commit()..build('')).text, '01.4|5-_789abcdefghij'); + }); + + test('no conflict, out of order', () { + var txn = TextEditTransaction(original, file); + txn.edit(2, 4, '.'); + txn.edit(5, 5, '|'); + + // Regresion test for issue #404: there is no conflict/overlap for edits + // that don't remove any of the original code. + txn.edit(6, 7, '_'); + txn.edit(6, 6, '-'); + expect((txn.commit()..build('')).text, '01.4|5-_789abcdefghij'); + }); + + test('conflict', () { + var txn = TextEditTransaction(original, file); + txn.edit(2, 4, '.'); + txn.edit(3, 3, '-'); + expect( + () => txn.commit(), + throwsA( + predicate((e) => e.toString().contains('overlapping edits')))); + }); + }); + + test('generated source maps', () { + var original = + '0123456789\n0*23456789\n01*3456789\nabcdefghij\nabcd*fghij\n'; + var file = SourceFile.fromString(original); + var txn = TextEditTransaction(original, file); + txn.edit(27, 29, '__\n '); + txn.edit(34, 35, '___'); + var printer = (txn.commit()..build('')); + var output = printer.text; + var map = parse(printer.map!); + expect(output, + '0123456789\n0*23456789\n01*34__\n 789\na___cdefghij\nabcd*fghij\n'); + + // Line 1 and 2 are unmodified: mapping any column returns the beginning + // of the corresponding line: + expect( + _span(1, 1, map, file), + 'line 1, column 1: \n' + ' ,\n' + '1 | 0123456789\n' + ' | ^\n' + " '"); + expect( + _span(1, 5, map, file), + 'line 1, column 1: \n' + ' ,\n' + '1 | 0123456789\n' + ' | ^\n' + " '"); + expect( + _span(2, 1, map, file), + 'line 2, column 1: \n' + ' ,\n' + '2 | 0*23456789\n' + ' | ^\n' + " '"); + expect( + _span(2, 8, map, file), + 'line 2, column 1: \n' + ' ,\n' + '2 | 0*23456789\n' + ' | ^\n' + " '"); + + // Line 3 is modified part way: mappings before the edits have the right + // mapping, after the edits the mapping is null. + expect( + _span(3, 1, map, file), + 'line 3, column 1: \n' + ' ,\n' + '3 | 01*3456789\n' + ' | ^\n' + " '"); + expect( + _span(3, 5, map, file), + 'line 3, column 1: \n' + ' ,\n' + '3 | 01*3456789\n' + ' | ^\n' + " '"); + + // Start of edits map to beginning of the edit secion: + expect( + _span(3, 6, map, file), + 'line 3, column 6: \n' + ' ,\n' + '3 | 01*3456789\n' + ' | ^\n' + " '"); + expect( + _span(3, 7, map, file), + 'line 3, column 6: \n' + ' ,\n' + '3 | 01*3456789\n' + ' | ^\n' + " '"); + + // Lines added have no mapping (they should inherit the last mapping), + // but the end of the edit region continues were we left off: + expect(_span(4, 1, map, file), isNull); + expect( + _span(4, 5, map, file), + 'line 3, column 8: \n' + ' ,\n' + '3 | 01*3456789\n' + ' | ^\n' + " '"); + + // Subsequent lines are still mapped correctly: + // a (in a___cd...) + expect( + _span(5, 1, map, file), + 'line 4, column 1: \n' + ' ,\n' + '4 | abcdefghij\n' + ' | ^\n' + " '"); + // _ (in a___cd...) + expect( + _span(5, 2, map, file), + 'line 4, column 2: \n' + ' ,\n' + '4 | abcdefghij\n' + ' | ^\n' + " '"); + // _ (in a___cd...) + expect( + _span(5, 3, map, file), + 'line 4, column 2: \n' + ' ,\n' + '4 | abcdefghij\n' + ' | ^\n' + " '"); + // _ (in a___cd...) + expect( + _span(5, 4, map, file), + 'line 4, column 2: \n' + ' ,\n' + '4 | abcdefghij\n' + ' | ^\n' + " '"); + // c (in a___cd...) + expect( + _span(5, 5, map, file), + 'line 4, column 3: \n' + ' ,\n' + '4 | abcdefghij\n' + ' | ^\n' + " '"); + expect( + _span(6, 1, map, file), + 'line 5, column 1: \n' + ' ,\n' + '5 | abcd*fghij\n' + ' | ^\n' + " '"); + expect( + _span(6, 8, map, file), + 'line 5, column 1: \n' + ' ,\n' + '5 | abcd*fghij\n' + ' | ^\n' + " '"); + }); +} + +String? _span(int line, int column, Mapping map, SourceFile file) => + map.spanFor(line - 1, column - 1, files: {'': file})?.message('').trim(); diff --git a/pkgs/source_maps/test/utils_test.dart b/pkgs/source_maps/test/utils_test.dart new file mode 100644 index 000000000..2516d1e47 --- /dev/null +++ b/pkgs/source_maps/test/utils_test.dart @@ -0,0 +1,53 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Tests for the binary search utility algorithm. +library; + +import 'package:source_maps/src/utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('binary search', () { + test('empty', () { + expect(binarySearch([], (x) => true), -1); + }); + + test('single element', () { + expect(binarySearch([1], (x) => true), 0); + expect(binarySearch([1], (x) => false), 1); + }); + + test('no matches', () { + var list = [1, 2, 3, 4, 5, 6, 7]; + expect(binarySearch(list, (x) => false), list.length); + }); + + test('all match', () { + var list = [1, 2, 3, 4, 5, 6, 7]; + expect(binarySearch(list, (x) => true), 0); + }); + + test('compare with linear search', () { + for (var size = 0; size < 100; size++) { + var list = []; + for (var i = 0; i < size; i++) { + list.add(i); + } + for (var pos = 0; pos <= size; pos++) { + expect(binarySearch(list, (x) => x >= pos), + _linearSearch(list, (x) => x >= pos)); + } + } + }); + }); +} + +int _linearSearch(List list, bool Function(T) predicate) { + if (list.isEmpty) return -1; + for (var i = 0; i < list.length; i++) { + if (predicate(list[i])) return i; + } + return list.length; +} diff --git a/pkgs/source_maps/test/vlq_test.dart b/pkgs/source_maps/test/vlq_test.dart new file mode 100644 index 000000000..4568cffc4 --- /dev/null +++ b/pkgs/source_maps/test/vlq_test.dart @@ -0,0 +1,59 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:math'; + +import 'package:source_maps/src/vlq.dart'; +import 'package:test/test.dart'; + +void main() { + test('encode and decode - simple values', () { + expect(encodeVlq(1).join(''), 'C'); + expect(encodeVlq(2).join(''), 'E'); + expect(encodeVlq(3).join(''), 'G'); + expect(encodeVlq(100).join(''), 'oG'); + expect(decodeVlq('C'.split('').iterator), 1); + expect(decodeVlq('E'.split('').iterator), 2); + expect(decodeVlq('G'.split('').iterator), 3); + expect(decodeVlq('oG'.split('').iterator), 100); + }); + + test('encode and decode', () { + for (var i = -10000; i < 10000; i++) { + _checkEncodeDecode(i); + } + }); + + test('only 32-bit ints allowed', () { + var maxInt = (pow(2, 31) as int) - 1; + var minInt = -(pow(2, 31) as int); + _checkEncodeDecode(maxInt - 1); + _checkEncodeDecode(minInt + 1); + _checkEncodeDecode(maxInt); + _checkEncodeDecode(minInt); + + expect(encodeVlq(minInt).join(''), 'hgggggE'); + expect(decodeVlq('hgggggE'.split('').iterator), minInt); + + expect(() => encodeVlq(maxInt + 1), throwsA(anything)); + expect(() => encodeVlq(maxInt + 2), throwsA(anything)); + expect(() => encodeVlq(minInt - 1), throwsA(anything)); + expect(() => encodeVlq(minInt - 2), throwsA(anything)); + + // if we allowed more than 32 bits, these would be the expected encodings + // for the large numbers above. + expect(() => decodeVlq('ggggggE'.split('').iterator), throwsA(anything)); + expect(() => decodeVlq('igggggE'.split('').iterator), throwsA(anything)); + expect(() => decodeVlq('jgggggE'.split('').iterator), throwsA(anything)); + expect(() => decodeVlq('lgggggE'.split('').iterator), throwsA(anything)); + }, + // This test uses integers so large they overflow in JS. + testOn: 'dart-vm'); +} + +void _checkEncodeDecode(int value) { + var encoded = encodeVlq(value); + expect(decodeVlq(encoded.iterator), value); + expect(decodeVlq(encoded.join('').split('').iterator), value); +} diff --git a/pkgs/source_span/.gitignore b/pkgs/source_span/.gitignore new file mode 100644 index 000000000..ab3cb76e6 --- /dev/null +++ b/pkgs/source_span/.gitignore @@ -0,0 +1,16 @@ +# Don’t commit the following directories created by pub. +.buildlog +.dart_tool/ +.pub/ +build/ +packages +.packages + +# Or the files created by dart2js. +*.dart.js +*.js_ +*.js.deps +*.js.map + +# Include when developing application packages. +pubspec.lock diff --git a/pkgs/source_span/CHANGELOG.md b/pkgs/source_span/CHANGELOG.md new file mode 100644 index 000000000..b8319d773 --- /dev/null +++ b/pkgs/source_span/CHANGELOG.md @@ -0,0 +1,240 @@ +## 1.10.1 + +* Require Dart 3.1 +* Move to `dart-lang/tools` monorepo. + +## 1.10.0 + +* Add a `SourceFile.codeUnits` property. +* Require Dart 2.18 +* Add an API usage example in `example/`. + +## 1.9.1 + +* Properly handle multi-line labels for multi-span highlights. + +* Populate the pubspec `repository` field. + +## 1.9.0 + +* Add `SourceSpanWithContextExtension.subspan` that returns a + `SourceSpanWithContext` rather than a plain `SourceSpan`. + +## 1.8.2 + +* Fix a bug where highlighting multiple spans with `null` URLs could cause an + assertion error. Now when multiple spans are passed with `null` URLs, they're + highlighted as though they all come from different source files. + +## 1.8.1 + +* Fix a bug where the URL header for the highlights with multiple files would + get omitted only one span has a non-null URI. + +## 1.8.0 + +* Stable release for null safety. + +## 1.7.0 + +* Add a `SourceSpan.subspan()` extension method which returns a slice of an + existing source span. + +## 1.6.0 + +* Add support for highlighting multiple source spans at once, providing more + context for span-based messages. This is exposed through the new APIs + `SourceSpan.highlightMultiple()` and `SourceSpan.messageMultiple()` (both + extension methods), `MultiSourceSpanException`, and + `MultiSourceSpanFormatException`. + +## 1.5.6 + +* Fix padding around line numbers that are powers of 10 in + `FileSpan.highlight()`. + +## 1.5.5 + +* Fix a bug where `FileSpan.highlight()` would crash for spans that covered a + trailing newline and a single additional empty line. + +## 1.5.4 + +* `FileSpan.highlight()` now properly highlights point spans at the beginning of + lines. + +## 1.5.3 + +* Fix an edge case where `FileSpan.highlight()` would put the highlight + indicator in the wrong position when highlighting a point span after the end + of a file. + +## 1.5.2 + +* `SourceFile.span()` now goes to the end of the file by default, rather than + ending one character before the end of the file. This matches the documented + behavior. + +* `FileSpan.context` now includes the full line on which the span appears for + empty spans at the beginning and end of lines. + +* Fix an edge case where `FileSpan.highlight()` could crash when highlighting a + span that ended with an empty line. + +## 1.5.1 + +* Produce better source span highlights for multi-line spans that cover the + entire last line of the span, including the newline. + +* Produce better source span highlights for spans that contain Windows-style + newlines. + +## 1.5.0 + +* Improve the output of `SourceSpan.highlight()` and `SourceSpan.message()`: + + * They now include line numbers. + * They will now print every line of a multiline span. + * They will now use Unicode box-drawing characters by default (this can be + controlled using [`term_glyph.ascii`][]). + +[`term_glyph.ascii`]: https://pub.dartlang.org/documentation/term_glyph/latest/term_glyph/ascii.html + +## 1.4.1 + +* Set max SDK version to `<3.0.0`, and adjust other dependencies. + +## 1.4.0 + +* The `new SourceFile()` constructor is deprecated. This constructed a source + file from a string's runes, rather than its code units, which runs counter to + the way Dart handles strings otherwise. The `new StringFile.fromString()` + constructor (see below) should be used instead. + +* The `new SourceFile.fromString()` constructor was added. This works like `new + SourceFile()`, except it uses code units rather than runes. + +* The current behavior when characters larger than `0xFFFF` are passed to `new + SourceFile.decoded()` is now considered deprecated. + +## 1.3.1 + +* Properly highlight spans for lines that include tabs with + `SourceSpan.highlight()` and `SourceSpan.message()`. + +## 1.3.0 + +* Add `SourceSpan.highlight()`, which returns just the highlighted text that + would be included in `SourceSpan.message()`. + +## 1.2.4 + +* Fix a new strong mode error. + +## 1.2.3 + +* Fix a bug where a point span at the end of a file without a trailing newline + would be printed incorrectly. + +## 1.2.2 + +* Allow `SourceSpanException.message`, `SourceSpanFormatException.source`, and + `SourceSpanWithContext.context` to be overridden in strong mode. + +## 1.2.1 + +* Fix the declared type of `FileSpan.start` and `FileSpan.end`. In 1.2.0 these + were mistakenly changed from `FileLocation` to `SourceLocation`. + +## 1.2.0 + +* **Deprecated:** Extending `SourceLocation` directly is deprecated. Instead, + extend the new `SourceLocationBase` class or mix in the new + `SourceLocationMixin` mixin. + +* Dramatically improve the performance of `FileLocation`. + +## 1.1.6 + +* Optimize `getLine()` in `SourceFile` when repeatedly called. + +## 1.1.5 + +* Fixed another case in which `FileSpan.union` could throw an exception for + external implementations of `FileSpan`. + +## 1.1.4 + +* Eliminated dart2js warning about overriding `==`, but not `hashCode`. + +## 1.1.3 + +* `FileSpan.compareTo`, `FileSpan.==`, `FileSpan.union`, and `FileSpan.expand` + no longer throw exceptions for external implementations of `FileSpan`. + +* `FileSpan.hashCode` now fully agrees with `FileSpan.==`. + +## 1.1.2 + +* Fixed validation in `SourceSpanWithContext` to allow multiple occurrences of + `text` within `context`. + +## 1.1.1 + +* Fixed `FileSpan`'s context to include the full span text, not just the first + line of it. + +## 1.1.0 + +* Added `SourceSpanWithContext`: a span that also includes the full line of text + that contains the span. + +## 1.0.3 + +* Cleanup equality operator to accept any Object rather than just a + `SourceLocation`. + +## 1.0.2 + +* Avoid unintentionally allocating extra objects for internal `FileSpan` + operations. + +* Ensure that `SourceSpan.operator==` works on arbitrary `Object`s. + +## 1.0.1 + +* Use a more compact internal representation for `FileSpan`. + +## 1.0.0 + +This package was extracted from the +[`source_maps`](https://pub.dev/packages/source_maps) package, but the +API has many differences. Among them: + +* `Span` has been renamed to `SourceSpan` and `Location` has been renamed to + `SourceLocation` to clarify their purpose and maintain consistency with the + package name. Likewise, `SpanException` is now `SourceSpanException` and + `SpanFormatException` is not `SourceSpanFormatException`. + +* `FixedSpan` and `FixedLocation` have been rolled into the `Span` and + `Location` classes, respectively. + +* `SourceFile` is more aggressive about validating its arguments. Out-of-bounds + lines, columns, and offsets will now throw errors rather than be silently + clamped. + +* `SourceSpan.sourceUrl`, `SourceLocation.sourceUrl`, and `SourceFile.url` now + return `Uri` objects rather than `String`s. The constructors allow either + `String`s or `Uri`s. + +* `Span.getLocationMessage` and `SourceFile.getLocationMessage` are now + `SourceSpan.message` and `SourceFile.message`, respectively. Rather than + taking both a `useColor` and a `color` parameter, they now take a single + `color` parameter that controls both whether and which color is used. + +* `Span.isIdentifier` has been removed. This property doesn't make sense outside + of a source map context. + +* `SourceFileSegment` has been removed. This class wasn't widely used and was + inconsistent in its choice of which parameters were considered relative and + which absolute. diff --git a/pkgs/source_span/LICENSE b/pkgs/source_span/LICENSE new file mode 100644 index 000000000..000cd7bec --- /dev/null +++ b/pkgs/source_span/LICENSE @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/source_span/README.md b/pkgs/source_span/README.md new file mode 100644 index 000000000..b4ce25fcf --- /dev/null +++ b/pkgs/source_span/README.md @@ -0,0 +1,21 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/source_span.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/source_span.yaml) +[![pub package](https://img.shields.io/pub/v/source_span.svg)](https://pub.dev/packages/source_span) +[![package publisher](https://img.shields.io/pub/publisher/source_span.svg)](https://pub.dev/packages/source_span/publisher) + +## About this package + +`source_span` is a library for tracking locations in source code. It's designed +to provide a standard representation for source code locations and spans so that +disparate packages can easily pass them among one another, and to make it easy +to generate human-friendly messages associated with a given piece of code. + +The most commonly-used class is the package's namesake, `SourceSpan`. It +represents a span of characters in some source file, and is often attached to an +object that has been parsed to indicate where it was parsed from. It provides +access to the text of the span via `SourceSpan.text` and can be used to produce +human-friendly messages using `SourceSpan.message()`. + +When parsing code from a file, `SourceFile` is useful. Not only does it provide +an efficient means of computing line and column numbers, `SourceFile.span()` +returns special `FileSpan`s that are able to provide more context for their +error messages. diff --git a/pkgs/source_span/analysis_options.yaml b/pkgs/source_span/analysis_options.yaml new file mode 100644 index 000000000..d2ebdbfe8 --- /dev/null +++ b/pkgs/source_span/analysis_options.yaml @@ -0,0 +1,32 @@ +# https://dart.dev/guides/language/analysis-options +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + - avoid_bool_literals_in_conditional_expressions + - avoid_classes_with_only_static_members + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_returning_this + - avoid_unused_constructor_parameters + - avoid_void_async + - cancel_subscriptions + - cascade_invocations + - join_return_with_assignment + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - prefer_const_declarations + - prefer_expression_function_bodies + - prefer_final_locals + - unnecessary_await_in_return + - unnecessary_raw_strings + - use_if_null_to_convert_nulls_to_bools + - use_raw_strings + - use_string_buffers diff --git a/pkgs/source_span/example/main.dart b/pkgs/source_span/example/main.dart new file mode 100644 index 000000000..e29676590 --- /dev/null +++ b/pkgs/source_span/example/main.dart @@ -0,0 +1,51 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:io'; + +import 'package:source_span/source_span.dart'; + +void main(List args) { + final file = File('README.md'); + final contents = file.readAsStringSync(); + + final sourceFile = SourceFile.fromString(contents, url: file.uri); + final spans = _parseFile(contents, sourceFile); + + for (var span in spans.take(30)) { + print('[${span.start.line + 1}:${span.start.column + 1}] ${span.text}'); + } +} + +Iterable _parseFile(String contents, SourceFile sourceFile) sync* { + var wordStart = 0; + var inWhiteSpace = true; + + for (var i = 0; i < contents.length; i++) { + final codeUnit = contents.codeUnitAt(i); + + if (codeUnit == _eol || codeUnit == _space) { + if (!inWhiteSpace) { + inWhiteSpace = true; + + // emit a word + yield sourceFile.span(wordStart, i); + } + } else { + if (inWhiteSpace) { + inWhiteSpace = false; + + wordStart = i; + } + } + } + + if (!inWhiteSpace) { + // emit a word + yield sourceFile.span(wordStart, contents.length); + } +} + +const int _eol = 10; +const int _space = 32; diff --git a/pkgs/source_span/lib/source_span.dart b/pkgs/source_span/lib/source_span.dart new file mode 100644 index 000000000..534a3a7aa --- /dev/null +++ b/pkgs/source_span/lib/source_span.dart @@ -0,0 +1,11 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'src/file.dart'; +export 'src/location.dart'; +export 'src/location_mixin.dart'; +export 'src/span.dart'; +export 'src/span_exception.dart'; +export 'src/span_mixin.dart'; +export 'src/span_with_context.dart'; diff --git a/pkgs/source_span/lib/src/charcode.dart b/pkgs/source_span/lib/src/charcode.dart new file mode 100644 index 000000000..51826380a --- /dev/null +++ b/pkgs/source_span/lib/src/charcode.dart @@ -0,0 +1,15 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// "Carriage return" control character. +const int $cr = 0x0D; + +/// "Line feed" control character. +const int $lf = 0x0A; + +/// Space character. +const int $space = 0x20; + +/// "Horizontal Tab" control character, common name. +const int $tab = 0x09; diff --git a/pkgs/source_span/lib/src/colors.dart b/pkgs/source_span/lib/src/colors.dart new file mode 100644 index 000000000..b48d468ca --- /dev/null +++ b/pkgs/source_span/lib/src/colors.dart @@ -0,0 +1,12 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// Color constants used for generating messages. +const String red = '\u001b[31m'; + +const String yellow = '\u001b[33m'; + +const String blue = '\u001b[34m'; + +const String none = '\u001b[0m'; diff --git a/pkgs/source_span/lib/src/file.dart b/pkgs/source_span/lib/src/file.dart new file mode 100644 index 000000000..74c923459 --- /dev/null +++ b/pkgs/source_span/lib/src/file.dart @@ -0,0 +1,454 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'location.dart'; +import 'location_mixin.dart'; +import 'span.dart'; +import 'span_mixin.dart'; +import 'span_with_context.dart'; + +// Constants to determine end-of-lines. +const int _lf = 10; +const int _cr = 13; + +/// A class representing a source file. +/// +/// This doesn't necessarily have to correspond to a file on disk, just a chunk +/// of text usually with a URL associated with it. +class SourceFile { + /// The URL where the source file is located. + /// + /// This may be null, indicating that the URL is unknown or unavailable. + final Uri? url; + + /// An array of offsets for each line beginning in the file. + /// + /// Each offset refers to the first character *after* the newline. If the + /// source file has a trailing newline, the final offset won't actually be in + /// the file. + final _lineStarts = [0]; + + /// The code units of the characters in the file. + /// + /// If this was constructed with the deprecated `SourceFile()` constructor, + /// this will instead contain the code _points_ of the characters in the file + /// (so characters above 2^16 are represented as individual integers rather + /// than surrogate pairs). + List get codeUnits => _decodedChars; + + /// The code units of the characters in this file. + final Uint32List _decodedChars; + + /// The length of the file in characters. + int get length => _decodedChars.length; + + /// The number of lines in the file. + int get lines => _lineStarts.length; + + /// The line that the offset fell on the last time [getLine] was called. + /// + /// In many cases, sequential calls to getLine() are for nearby, usually + /// increasing offsets. In that case, we can find the line for an offset + /// quickly by first checking to see if the offset is on the same line as the + /// previous result. + int? _cachedLine; + + /// This constructor is deprecated. + /// + /// Use [SourceFile.fromString] instead. + @Deprecated('Will be removed in 2.0.0') + SourceFile(String text, {Object? url}) : this.decoded(text.runes, url: url); + + /// Creates a new source file from [text]. + /// + /// [url] may be either a [String], a [Uri], or `null`. + SourceFile.fromString(String text, {Object? url}) + : this.decoded(text.codeUnits, url: url); + + /// Creates a new source file from a list of decoded code units. + /// + /// [url] may be either a [String], a [Uri], or `null`. + /// + /// Currently, if [decodedChars] contains characters larger than `0xFFFF`, + /// they'll be treated as single characters rather than being split into + /// surrogate pairs. **This behavior is deprecated**. For + /// forwards-compatibility, callers should only pass in characters less than + /// or equal to `0xFFFF`. + SourceFile.decoded(Iterable decodedChars, {Object? url}) + : url = url is String ? Uri.parse(url) : url as Uri?, + _decodedChars = Uint32List.fromList(decodedChars.toList()) { + for (var i = 0; i < _decodedChars.length; i++) { + var c = _decodedChars[i]; + if (c == _cr) { + // Return not followed by newline is treated as a newline + final j = i + 1; + if (j >= _decodedChars.length || _decodedChars[j] != _lf) c = _lf; + } + if (c == _lf) _lineStarts.add(i + 1); + } + } + + /// Returns a span from [start] to [end] (exclusive). + /// + /// If [end] isn't passed, it defaults to the end of the file. + FileSpan span(int start, [int? end]) { + end ??= length; + return _FileSpan(this, start, end); + } + + /// Returns a location at [offset]. + FileLocation location(int offset) => FileLocation._(this, offset); + + /// Gets the 0-based line corresponding to [offset]. + int getLine(int offset) { + if (offset < 0) { + throw RangeError('Offset may not be negative, was $offset.'); + } else if (offset > length) { + throw RangeError('Offset $offset must not be greater than the number ' + 'of characters in the file, $length.'); + } + + if (offset < _lineStarts.first) return -1; + if (offset >= _lineStarts.last) return _lineStarts.length - 1; + + if (_isNearCachedLine(offset)) return _cachedLine!; + + _cachedLine = _binarySearch(offset) - 1; + return _cachedLine!; + } + + /// Returns `true` if [offset] is near [_cachedLine]. + /// + /// Checks on [_cachedLine] and the next line. If it's on the next line, it + /// updates [_cachedLine] to point to that. + bool _isNearCachedLine(int offset) { + if (_cachedLine == null) return false; + final cachedLine = _cachedLine!; + + // See if it's before the cached line. + if (offset < _lineStarts[cachedLine]) return false; + + // See if it's on the cached line. + if (cachedLine >= _lineStarts.length - 1 || + offset < _lineStarts[cachedLine + 1]) { + return true; + } + + // See if it's on the next line. + if (cachedLine >= _lineStarts.length - 2 || + offset < _lineStarts[cachedLine + 2]) { + _cachedLine = cachedLine + 1; + return true; + } + + return false; + } + + /// Binary search through [_lineStarts] to find the line containing [offset]. + /// + /// Returns the index of the line in [_lineStarts]. + int _binarySearch(int offset) { + var min = 0; + var max = _lineStarts.length - 1; + while (min < max) { + final half = min + ((max - min) ~/ 2); + if (_lineStarts[half] > offset) { + max = half; + } else { + min = half + 1; + } + } + + return max; + } + + /// Gets the 0-based column corresponding to [offset]. + /// + /// If [line] is passed, it's assumed to be the line containing [offset] and + /// is used to more efficiently compute the column. + int getColumn(int offset, {int? line}) { + if (offset < 0) { + throw RangeError('Offset may not be negative, was $offset.'); + } else if (offset > length) { + throw RangeError('Offset $offset must be not be greater than the ' + 'number of characters in the file, $length.'); + } + + if (line == null) { + line = getLine(offset); + } else if (line < 0) { + throw RangeError('Line may not be negative, was $line.'); + } else if (line >= lines) { + throw RangeError('Line $line must be less than the number of ' + 'lines in the file, $lines.'); + } + + final lineStart = _lineStarts[line]; + if (lineStart > offset) { + throw RangeError('Line $line comes after offset $offset.'); + } + + return offset - lineStart; + } + + /// Gets the offset for a [line] and [column]. + /// + /// [column] defaults to 0. + int getOffset(int line, [int? column]) { + column ??= 0; + + if (line < 0) { + throw RangeError('Line may not be negative, was $line.'); + } else if (line >= lines) { + throw RangeError('Line $line must be less than the number of ' + 'lines in the file, $lines.'); + } else if (column < 0) { + throw RangeError('Column may not be negative, was $column.'); + } + + final result = _lineStarts[line] + column; + if (result > length || + (line + 1 < lines && result >= _lineStarts[line + 1])) { + throw RangeError("Line $line doesn't have $column columns."); + } + + return result; + } + + /// Returns the text of the file from [start] to [end] (exclusive). + /// + /// If [end] isn't passed, it defaults to the end of the file. + String getText(int start, [int? end]) => + String.fromCharCodes(_decodedChars.sublist(start, end)); +} + +/// A [SourceLocation] within a [SourceFile]. +/// +/// Unlike the base [SourceLocation], [FileLocation] lazily computes its line +/// and column values based on its offset and the contents of [file]. +/// +/// A [FileLocation] can be created using [SourceFile.location]. +class FileLocation extends SourceLocationMixin implements SourceLocation { + /// The [file] that `this` belongs to. + final SourceFile file; + + @override + final int offset; + + @override + Uri? get sourceUrl => file.url; + + @override + int get line => file.getLine(offset); + + @override + int get column => file.getColumn(offset); + + FileLocation._(this.file, this.offset) { + if (offset < 0) { + throw RangeError('Offset may not be negative, was $offset.'); + } else if (offset > file.length) { + throw RangeError('Offset $offset must not be greater than the number ' + 'of characters in the file, ${file.length}.'); + } + } + + @override + FileSpan pointSpan() => _FileSpan(file, offset, offset); +} + +/// A [SourceSpan] within a [SourceFile]. +/// +/// Unlike the base [SourceSpan], [FileSpan] lazily computes its line and column +/// values based on its offset and the contents of [file]. [SourceSpan.message] +/// is also able to provide more context then [SourceSpan.message], and +/// [SourceSpan.union] will return a [FileSpan] if possible. +/// +/// A [FileSpan] can be created using [SourceFile.span]. +abstract class FileSpan implements SourceSpanWithContext { + /// The [file] that `this` belongs to. + SourceFile get file; + + @override + FileLocation get start; + + @override + FileLocation get end; + + /// Returns a new span that covers both `this` and [other]. + /// + /// Unlike [union], [other] may be disjoint from `this`. If it is, the text + /// between the two will be covered by the returned span. + FileSpan expand(FileSpan other); +} + +/// The implementation of [FileSpan]. +/// +/// This is split into a separate class so that `is _FileSpan` checks can be run +/// to make certain operations more efficient. If we used `is FileSpan`, that +/// would break if external classes implemented the interface. +class _FileSpan extends SourceSpanMixin implements FileSpan { + @override + final SourceFile file; + + /// The offset of the beginning of the span. + /// + /// [start] is lazily generated from this to avoid allocating unnecessary + /// objects. + final int _start; + + /// The offset of the end of the span. + /// + /// [end] is lazily generated from this to avoid allocating unnecessary + /// objects. + final int _end; + + @override + Uri? get sourceUrl => file.url; + + @override + int get length => _end - _start; + + @override + FileLocation get start => FileLocation._(file, _start); + + @override + FileLocation get end => FileLocation._(file, _end); + + @override + String get text => file.getText(_start, _end); + + @override + String get context { + final endLine = file.getLine(_end); + final endColumn = file.getColumn(_end); + + int? endOffset; + if (endColumn == 0 && endLine != 0) { + // If [end] is at the very beginning of the line, the span covers the + // previous newline, so we only want to include the previous line in the + // context... + + if (length == 0) { + // ...unless this is a point span, in which case we want to include the + // next line (or the empty string if this is the end of the file). + return endLine == file.lines - 1 + ? '' + : file.getText( + file.getOffset(endLine), file.getOffset(endLine + 1)); + } + + endOffset = _end; + } else if (endLine == file.lines - 1) { + // If the span covers the last line of the file, the context should go all + // the way to the end of the file. + endOffset = file.length; + } else { + // Otherwise, the context should cover the full line on which [end] + // appears. + endOffset = file.getOffset(endLine + 1); + } + + return file.getText(file.getOffset(file.getLine(_start)), endOffset); + } + + _FileSpan(this.file, this._start, this._end) { + if (_end < _start) { + throw ArgumentError('End $_end must come after start $_start.'); + } else if (_end > file.length) { + throw RangeError('End $_end must not be greater than the number ' + 'of characters in the file, ${file.length}.'); + } else if (_start < 0) { + throw RangeError('Start may not be negative, was $_start.'); + } + } + + @override + int compareTo(SourceSpan other) { + if (other is! _FileSpan) return super.compareTo(other); + + final result = _start.compareTo(other._start); + return result == 0 ? _end.compareTo(other._end) : result; + } + + @override + SourceSpan union(SourceSpan other) { + if (other is! FileSpan) return super.union(other); + + final span = expand(other); + + if (other is _FileSpan) { + if (_start > other._end || other._start > _end) { + throw ArgumentError('Spans $this and $other are disjoint.'); + } + } else { + if (_start > other.end.offset || other.start.offset > _end) { + throw ArgumentError('Spans $this and $other are disjoint.'); + } + } + + return span; + } + + @override + bool operator ==(Object other) { + if (other is! FileSpan) return super == other; + if (other is! _FileSpan) { + return super == other && sourceUrl == other.sourceUrl; + } + + return _start == other._start && + _end == other._end && + sourceUrl == other.sourceUrl; + } + + @override + int get hashCode => Object.hash(_start, _end, sourceUrl); + + /// Returns a new span that covers both `this` and [other]. + /// + /// Unlike [union], [other] may be disjoint from `this`. If it is, the text + /// between the two will be covered by the returned span. + @override + FileSpan expand(FileSpan other) { + if (sourceUrl != other.sourceUrl) { + throw ArgumentError('Source URLs "$sourceUrl" and ' + " \"${other.sourceUrl}\" don't match."); + } + + if (other is _FileSpan) { + final start = math.min(_start, other._start); + final end = math.max(_end, other._end); + return _FileSpan(file, start, end); + } else { + final start = math.min(_start, other.start.offset); + final end = math.max(_end, other.end.offset); + return _FileSpan(file, start, end); + } + } + + /// See `SourceSpanExtension.subspan`. + FileSpan subspan(int start, [int? end]) { + RangeError.checkValidRange(start, end, length); + if (start == 0 && (end == null || end == length)) return this; + return file.span(_start + start, end == null ? _end : _start + end); + } +} + +// TODO(#52): Move these to instance methods in the next breaking release. +/// Extension methods on the [FileSpan] API. +extension FileSpanExtension on FileSpan { + /// See `SourceSpanExtension.subspan`. + FileSpan subspan(int start, [int? end]) { + RangeError.checkValidRange(start, end, length); + if (start == 0 && (end == null || end == length)) return this; + + final startOffset = this.start.offset; + return file.span( + startOffset + start, end == null ? this.end.offset : startOffset + end); + } +} diff --git a/pkgs/source_span/lib/src/highlighter.dart b/pkgs/source_span/lib/src/highlighter.dart new file mode 100644 index 000000000..19e04d02e --- /dev/null +++ b/pkgs/source_span/lib/src/highlighter.dart @@ -0,0 +1,727 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as p; +import 'package:term_glyph/term_glyph.dart' as glyph; + +import 'charcode.dart'; +import 'colors.dart' as colors; +import 'location.dart'; +import 'span.dart'; +import 'span_with_context.dart'; +import 'utils.dart'; + +/// A class for writing a chunk of text with a particular span highlighted. +class Highlighter { + /// The lines to display, including context around the highlighted spans. + final List<_Line> _lines; + + /// The color to highlight the primary [_Highlight] within its context, or + /// `null` if it should not be colored. + final String? _primaryColor; + + /// The color to highlight the secondary [_Highlight]s within their context, + /// or `null` if they should not be colored. + final String? _secondaryColor; + + /// The number of characters before the bar in the sidebar. + final int _paddingBeforeSidebar; + + /// The maximum number of multiline spans that cover any part of a single + /// line in [_lines]. + final int _maxMultilineSpans; + + /// Whether [_lines] includes lines from multiple different files. + final bool _multipleFiles; + + /// The buffer to which to write the result. + final _buffer = StringBuffer(); + + /// The number of spaces to render for hard tabs that appear in `_span.text`. + /// + /// We don't want to render raw tabs, because they'll mess up our character + /// alignment. + static const _spacesPerTab = 4; + + /// Creates a [Highlighter] that will return a string highlighting [span] + /// within the text of its file when [highlight] is called. + /// + /// [color] may either be a [String], a [bool], or `null`. If it's a string, + /// it indicates an [ANSI terminal color escape][] that should be used to + /// highlight [span]'s text (for example, `"\u001b[31m"` will color red). If + /// it's `true`, it indicates that the text should be highlighted using the + /// default color. If it's `false` or `null`, it indicates that no color + /// should be used. + /// + /// [ANSI terminal color escape]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors + Highlighter(SourceSpan span, {Object? color}) + : this._(_collateLines([_Highlight(span, primary: true)]), () { + if (color == true) return colors.red; + if (color == false) return null; + return color as String?; + }(), null); + + /// Creates a [Highlighter] that will return a string highlighting + /// [primarySpan] as well as all the spans in [secondarySpans] within the text + /// of their file when [highlight] is called. + /// + /// Each span has an associated label that will be written alongside it. For + /// [primarySpan] this message is [primaryLabel], and for [secondarySpans] the + /// labels are the map values. + /// + /// If [color] is `true`, this will use [ANSI terminal color escapes][] to + /// highlight the text. The [primarySpan] will be highlighted with + /// [primaryColor] (which defaults to red), and the [secondarySpans] will be + /// highlighted with [secondaryColor] (which defaults to blue). These + /// arguments are ignored if [color] is `false`. + /// + /// [ANSI terminal color escape]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors + Highlighter.multiple(SourceSpan primarySpan, String primaryLabel, + Map secondarySpans, + {bool color = false, String? primaryColor, String? secondaryColor}) + : this._( + _collateLines([ + _Highlight(primarySpan, label: primaryLabel, primary: true), + for (var entry in secondarySpans.entries) + _Highlight(entry.key, label: entry.value) + ]), + color ? (primaryColor ?? colors.red) : null, + color ? (secondaryColor ?? colors.blue) : null); + + Highlighter._(this._lines, this._primaryColor, this._secondaryColor) + : _paddingBeforeSidebar = 1 + + math.max( + // In a purely mathematical world, floor(log10(n)) would give the + // number of digits in n, but floating point errors render that + // unreliable in practice. + (_lines.last.number + 1).toString().length, + // If [_lines] aren't contiguous, we'll write "..." in place of a + // line number. + _contiguous(_lines) ? 0 : 3, + ), + _maxMultilineSpans = _lines + .map((line) => line.highlights + .where((highlight) => isMultiline(highlight.span)) + .length) + .reduce(math.max), + _multipleFiles = !isAllTheSame(_lines.map((line) => line.url)); + + /// Returns whether [lines] contains any adjacent lines from the same source + /// file that aren't adjacent in the original file. + static bool _contiguous(List<_Line> lines) { + for (var i = 0; i < lines.length - 1; i++) { + final thisLine = lines[i]; + final nextLine = lines[i + 1]; + if (thisLine.number + 1 != nextLine.number && + thisLine.url == nextLine.url) { + return false; + } + } + return true; + } + + /// Collect all the source lines from the contexts of all spans in + /// [highlights], and associates them with the highlights that cover them. + static List<_Line> _collateLines(List<_Highlight> highlights) { + // Assign spans without URLs opaque Objects as keys. Each such Object will + // be different, but they can then be used later on to determine which lines + // came from the same span even if they'd all otherwise have `null` URLs. + final highlightsByUrl = groupBy<_Highlight, Object>( + highlights, (highlight) => highlight.span.sourceUrl ?? Object()); + for (var list in highlightsByUrl.values) { + list.sort((highlight1, highlight2) => + highlight1.span.compareTo(highlight2.span)); + } + + return highlightsByUrl.entries.expand((entry) { + final url = entry.key; + final highlightsForFile = entry.value; + + // First, create a list of all the lines in the current file that we have + // context for along with their line numbers. + final lines = <_Line>[]; + for (var highlight in highlightsForFile) { + final context = highlight.span.context; + // If [highlight.span.context] contains lines prior to the one + // [highlight.span.text] appears on, write those first. + final lineStart = findLineStart( + context, highlight.span.text, highlight.span.start.column)!; + + final linesBeforeSpan = + '\n'.allMatches(context.substring(0, lineStart)).length; + + var lineNumber = highlight.span.start.line - linesBeforeSpan; + for (var line in context.split('\n')) { + // Only add a line if it hasn't already been added for a previous span + if (lines.isEmpty || lineNumber > lines.last.number) { + lines.add(_Line(line, lineNumber, url)); + } + lineNumber++; + } + } + + // Next, associate each line with each highlights that covers it. + final activeHighlights = <_Highlight>[]; + var highlightIndex = 0; + for (var line in lines) { + activeHighlights + .removeWhere((highlight) => highlight.span.end.line < line.number); + + final oldHighlightLength = activeHighlights.length; + for (var highlight in highlightsForFile.skip(highlightIndex)) { + if (highlight.span.start.line > line.number) break; + activeHighlights.add(highlight); + } + highlightIndex += activeHighlights.length - oldHighlightLength; + + line.highlights.addAll(activeHighlights); + } + + return lines; + }).toList(); + } + + /// Returns the highlighted span text. + /// + /// This method should only be called once. + String highlight() { + _writeFileStart(_lines.first.url); + + // Each index of this list represents a column after the sidebar that could + // contain a line indicating an active highlight. If it's `null`, that + // column is empty; if it contains a highlight, it should be drawn for that + // column. + final highlightsByColumn = + List<_Highlight?>.filled(_maxMultilineSpans, null); + + for (var i = 0; i < _lines.length; i++) { + final line = _lines[i]; + if (i > 0) { + final lastLine = _lines[i - 1]; + if (lastLine.url != line.url) { + _writeSidebar(end: glyph.upEnd); + _buffer.writeln(); + _writeFileStart(line.url); + } else if (lastLine.number + 1 != line.number) { + _writeSidebar(text: '...'); + _buffer.writeln(); + } + } + + // If a highlight covers the entire first line other than initial + // whitespace, don't bother pointing out exactly where it begins. Iterate + // in reverse so that longer highlights (which are sorted after shorter + // highlights) appear further out, leading to fewer crossed lines. + for (var highlight in line.highlights.reversed) { + if (isMultiline(highlight.span) && + highlight.span.start.line == line.number && + _isOnlyWhitespace( + line.text.substring(0, highlight.span.start.column))) { + replaceFirstNull(highlightsByColumn, highlight); + } + } + + _writeSidebar(line: line.number); + _buffer.write(' '); + _writeMultilineHighlights(line, highlightsByColumn); + if (highlightsByColumn.isNotEmpty) _buffer.write(' '); + + final primaryIdx = + line.highlights.indexWhere((highlight) => highlight.isPrimary); + final primary = primaryIdx == -1 ? null : line.highlights[primaryIdx]; + + if (primary != null) { + _writeHighlightedText( + line.text, + primary.span.start.line == line.number + ? primary.span.start.column + : 0, + primary.span.end.line == line.number + ? primary.span.end.column + : line.text.length, + color: _primaryColor); + } else { + _writeText(line.text); + } + _buffer.writeln(); + + // Always write the primary span's indicator first so that it's right next + // to the highlighted text. + if (primary != null) _writeIndicator(line, primary, highlightsByColumn); + for (var highlight in line.highlights) { + if (highlight.isPrimary) continue; + _writeIndicator(line, highlight, highlightsByColumn); + } + } + + _writeSidebar(end: glyph.upEnd); + return _buffer.toString(); + } + + /// Writes the beginning of the file highlight for the file with the given + /// [url] (or opaque object if it comes from a span with a null URL). + void _writeFileStart(Object url) { + if (!_multipleFiles || url is! Uri) { + _writeSidebar(end: glyph.downEnd); + } else { + _writeSidebar(end: glyph.topLeftCorner); + _colorize(() => _buffer.write('${glyph.horizontalLine * 2}>'), + color: colors.blue); + _buffer.write(' ${p.prettyUri(url)}'); + } + _buffer.writeln(); + } + + /// Writes the post-sidebar highlight bars for [line] according to + /// [highlightsByColumn]. + /// + /// If [current] is passed, it's the highlight for which an indicator is being + /// written. If it appears in [highlightsByColumn], a horizontal line is + /// written from its column to the rightmost column. + void _writeMultilineHighlights( + _Line line, List<_Highlight?> highlightsByColumn, + {_Highlight? current}) { + // Whether we've written a sidebar indicator for opening a new span on this + // line, and which color should be used for that indicator's rightward line. + var openedOnThisLine = false; + String? openedOnThisLineColor; + + final currentColor = current == null + ? null + : current.isPrimary + ? _primaryColor + : _secondaryColor; + var foundCurrent = false; + for (var highlight in highlightsByColumn) { + final startLine = highlight?.span.start.line; + final endLine = highlight?.span.end.line; + if (current != null && highlight == current) { + foundCurrent = true; + assert(startLine == line.number || endLine == line.number); + _colorize(() { + _buffer.write(startLine == line.number + ? glyph.topLeftCorner + : glyph.bottomLeftCorner); + }, color: currentColor); + } else if (foundCurrent) { + _colorize(() { + _buffer.write(highlight == null ? glyph.horizontalLine : glyph.cross); + }, color: currentColor); + } else if (highlight == null) { + if (openedOnThisLine) { + _colorize(() => _buffer.write(glyph.horizontalLine), + color: openedOnThisLineColor); + } else { + _buffer.write(' '); + } + } else { + _colorize(() { + final vertical = openedOnThisLine ? glyph.cross : glyph.verticalLine; + if (current != null) { + _buffer.write(vertical); + } else if (startLine == line.number) { + _colorize(() { + _buffer + .write(glyph.glyphOrAscii(openedOnThisLine ? '┬' : '┌', '/')); + }, color: openedOnThisLineColor); + openedOnThisLine = true; + openedOnThisLineColor ??= + highlight.isPrimary ? _primaryColor : _secondaryColor; + } else if (endLine == line.number && + highlight.span.end.column == line.text.length) { + _buffer.write(highlight.label == null + ? glyph.glyphOrAscii('└', r'\') + : vertical); + } else { + _colorize(() { + _buffer.write(vertical); + }, color: openedOnThisLineColor); + } + }, color: highlight.isPrimary ? _primaryColor : _secondaryColor); + } + } + } + + // Writes [text], with text between [startColumn] and [endColumn] colorized in + // the same way as [_colorize]. + void _writeHighlightedText(String text, int startColumn, int endColumn, + {required String? color}) { + _writeText(text.substring(0, startColumn)); + _colorize(() => _writeText(text.substring(startColumn, endColumn)), + color: color); + _writeText(text.substring(endColumn, text.length)); + } + + /// Writes an indicator for where [highlight] starts, ends, or both below + /// [line]. + /// + /// This may either add or remove [highlight] from [highlightsByColumn]. + void _writeIndicator( + _Line line, _Highlight highlight, List<_Highlight?> highlightsByColumn) { + final color = highlight.isPrimary ? _primaryColor : _secondaryColor; + if (!isMultiline(highlight.span)) { + _writeSidebar(); + _buffer.write(' '); + _writeMultilineHighlights(line, highlightsByColumn, current: highlight); + if (highlightsByColumn.isNotEmpty) _buffer.write(' '); + + final underlineLength = _colorize(() { + final start = _buffer.length; + _writeUnderline(line, highlight.span, + highlight.isPrimary ? '^' : glyph.horizontalLineBold); + return _buffer.length - start; + }, color: color); + _writeLabel(highlight, highlightsByColumn, underlineLength); + } else if (highlight.span.start.line == line.number) { + if (highlightsByColumn.contains(highlight)) return; + replaceFirstNull(highlightsByColumn, highlight); + + _writeSidebar(); + _buffer.write(' '); + _writeMultilineHighlights(line, highlightsByColumn, current: highlight); + _colorize(() => _writeArrow(line, highlight.span.start.column), + color: color); + _buffer.writeln(); + } else if (highlight.span.end.line == line.number) { + final coversWholeLine = highlight.span.end.column == line.text.length; + if (coversWholeLine && highlight.label == null) { + replaceWithNull(highlightsByColumn, highlight); + return; + } + + _writeSidebar(); + _buffer.write(' '); + _writeMultilineHighlights(line, highlightsByColumn, current: highlight); + + final underlineLength = _colorize(() { + final start = _buffer.length; + if (coversWholeLine) { + _buffer.write(glyph.horizontalLine * 3); + } else { + _writeArrow(line, math.max(highlight.span.end.column - 1, 0), + beginning: false); + } + return _buffer.length - start; + }, color: color); + _writeLabel(highlight, highlightsByColumn, underlineLength); + replaceWithNull(highlightsByColumn, highlight); + } + } + + /// Underlines the portion of [line] covered by [span] with repeated instances + /// of [character]. + void _writeUnderline(_Line line, SourceSpan span, String character) { + assert(!isMultiline(span)); + assert(line.text.contains(span.text), + '"${line.text}" should contain "${span.text}"'); + + var startColumn = span.start.column; + var endColumn = span.end.column; + + // Adjust the start and end columns to account for any tabs that were + // converted to spaces. + final tabsBefore = _countTabs(line.text.substring(0, startColumn)); + final tabsInside = _countTabs(line.text.substring(startColumn, endColumn)); + startColumn += tabsBefore * (_spacesPerTab - 1); + endColumn += (tabsBefore + tabsInside) * (_spacesPerTab - 1); + + _buffer + ..write(' ' * startColumn) + ..write(character * math.max(endColumn - startColumn, 1)); + } + + /// Write an arrow pointing to column [column] in [line]. + /// + /// If the arrow points to a tab character, this will point to the beginning + /// of the tab if [beginning] is `true` and the end if it's `false`. + void _writeArrow(_Line line, int column, {bool beginning = true}) { + final tabs = + _countTabs(line.text.substring(0, column + (beginning ? 0 : 1))); + _buffer + ..write(glyph.horizontalLine * (1 + column + tabs * (_spacesPerTab - 1))) + ..write('^'); + } + + /// Writes [highlight]'s label. + /// + /// The `_buffer` is assumed to be written to the point where the first line + /// of `highlight.label` can be written after a space, but this takes care of + /// writing indentation and highlight columns for later lines. + /// + /// The [highlightsByColumn] are used to write ongoing highlight lines if the + /// label is more than one line long. + /// + /// The [underlineLength] is the length of the line written between the + /// highlights and the beginning of the first label. + void _writeLabel(_Highlight highlight, List<_Highlight?> highlightsByColumn, + int underlineLength) { + final label = highlight.label; + if (label == null) { + _buffer.writeln(); + return; + } + + final lines = label.split('\n'); + final color = highlight.isPrimary ? _primaryColor : _secondaryColor; + _colorize(() => _buffer.write(' ${lines.first}'), color: color); + _buffer.writeln(); + + for (var text in lines.skip(1)) { + _writeSidebar(); + _buffer.write(' '); + for (var columnHighlight in highlightsByColumn) { + if (columnHighlight == null || columnHighlight == highlight) { + _buffer.write(' '); + } else { + _buffer.write(glyph.verticalLine); + } + } + + _buffer.write(' ' * underlineLength); + _colorize(() => _buffer.write(' $text'), color: color); + _buffer.writeln(); + } + } + + /// Writes a snippet from the source text, converting hard tab characters into + /// plain indentation. + void _writeText(String text) { + for (var char in text.codeUnits) { + if (char == $tab) { + _buffer.write(' ' * _spacesPerTab); + } else { + _buffer.writeCharCode(char); + } + } + } + + // Writes a sidebar to [buffer] that includes [line] as the line number if + // given and writes [end] at the end (defaults to [glyphs.verticalLine]). + // + // If [text] is given, it's used in place of the line number. It can't be + // passed at the same time as [line]. + void _writeSidebar({int? line, String? text, String? end}) { + assert(line == null || text == null); + + // Add 1 to line to convert from computer-friendly 0-indexed line numbers to + // human-friendly 1-indexed line numbers. + if (line != null) text = (line + 1).toString(); + _colorize(() { + _buffer + ..write((text ?? '').padRight(_paddingBeforeSidebar)) + ..write(end ?? glyph.verticalLine); + }, color: colors.blue); + } + + /// Returns the number of hard tabs in [text]. + int _countTabs(String text) { + var count = 0; + for (var char in text.codeUnits) { + if (char == $tab) count++; + } + return count; + } + + /// Returns whether [text] contains only space or tab characters. + bool _isOnlyWhitespace(String text) { + for (var char in text.codeUnits) { + if (char != $space && char != $tab) return false; + } + return true; + } + + /// Colors all text written to [_buffer] during [callback], if colorization is + /// enabled and [color] is not `null`. + T _colorize(T Function() callback, {required String? color}) { + if (_primaryColor != null && color != null) _buffer.write(color); + final result = callback(); + if (_primaryColor != null && color != null) _buffer.write(colors.none); + return result; + } +} + +/// Information about how to highlight a single section of a source file. +class _Highlight { + /// The section of the source file to highlight. + /// + /// This is normalized to make it easier for [Highlighter] to work with. + final SourceSpanWithContext span; + + /// Whether this is the primary span in the highlight. + /// + /// The primary span is highlighted with a different character and colored + /// differently than non-primary spans. + final bool isPrimary; + + /// The label to include inline when highlighting [span]. + /// + /// This helps distinguish clarify what each highlight means when multiple are + /// used in the same message. + final String? label; + + _Highlight(SourceSpan span, {String? label, bool primary = false}) + : span = (() { + var newSpan = _normalizeContext(span); + newSpan = _normalizeNewlines(newSpan); + newSpan = _normalizeTrailingNewline(newSpan); + return _normalizeEndOfLine(newSpan); + })(), + isPrimary = primary, + label = label?.replaceAll('\r\n', '\n'); + + /// Normalizes [span] to ensure that it's a [SourceSpanWithContext] whose + /// context actually contains its text at the expected column. + /// + /// If it's not already a [SourceSpanWithContext], adjust the start and end + /// locations' line and column fields so that the highlighter can assume they + /// match up with the context. + static SourceSpanWithContext _normalizeContext(SourceSpan span) => + span is SourceSpanWithContext && + findLineStart(span.context, span.text, span.start.column) != null + ? span + : SourceSpanWithContext( + SourceLocation(span.start.offset, + sourceUrl: span.sourceUrl, line: 0, column: 0), + SourceLocation(span.end.offset, + sourceUrl: span.sourceUrl, + line: countCodeUnits(span.text, $lf), + column: _lastLineLength(span.text)), + span.text, + span.text); + + /// Normalizes [span] to replace Windows-style newlines with Unix-style + /// newlines. + static SourceSpanWithContext _normalizeNewlines(SourceSpanWithContext span) { + final text = span.text; + if (!text.contains('\r\n')) return span; + + var endOffset = span.end.offset; + for (var i = 0; i < text.length - 1; i++) { + if (text.codeUnitAt(i) == $cr && text.codeUnitAt(i + 1) == $lf) { + endOffset--; + } + } + + return SourceSpanWithContext( + span.start, + SourceLocation(endOffset, + sourceUrl: span.sourceUrl, + line: span.end.line, + column: span.end.column), + text.replaceAll('\r\n', '\n'), + span.context.replaceAll('\r\n', '\n')); + } + + /// Normalizes [span] to remove a trailing newline from `span.context`. + /// + /// If necessary, also adjust `span.end` so that it doesn't point past where + /// the trailing newline used to be. + static SourceSpanWithContext _normalizeTrailingNewline( + SourceSpanWithContext span) { + if (!span.context.endsWith('\n')) return span; + + // If there's a full blank line on the end of [span.context], it's probably + // significant, so we shouldn't trim it. + if (span.text.endsWith('\n\n')) return span; + + final context = span.context.substring(0, span.context.length - 1); + var text = span.text; + var start = span.start; + var end = span.end; + if (span.text.endsWith('\n') && _isTextAtEndOfContext(span)) { + text = span.text.substring(0, span.text.length - 1); + if (text.isEmpty) { + end = start; + } else { + end = SourceLocation(span.end.offset - 1, + sourceUrl: span.sourceUrl, + line: span.end.line - 1, + column: _lastLineLength(context)); + start = span.start.offset == span.end.offset ? end : span.start; + } + } + return SourceSpanWithContext(start, end, text, context); + } + + /// Normalizes [span] so that the end location is at the end of a line rather + /// than at the beginning of the next line. + static SourceSpanWithContext _normalizeEndOfLine(SourceSpanWithContext span) { + if (span.end.column != 0) return span; + if (span.end.line == span.start.line) return span; + + final text = span.text.substring(0, span.text.length - 1); + + return SourceSpanWithContext( + span.start, + SourceLocation(span.end.offset - 1, + sourceUrl: span.sourceUrl, + line: span.end.line - 1, + column: text.length - text.lastIndexOf('\n') - 1), + text, + // If the context also ends with a newline, it's possible that we don't + // have the full context for that line, so we shouldn't print it at all. + span.context.endsWith('\n') + ? span.context.substring(0, span.context.length - 1) + : span.context); + } + + /// Returns the length of the last line in [text], whether or not it ends in a + /// newline. + static int _lastLineLength(String text) { + if (text.isEmpty) { + return 0; + } else if (text.codeUnitAt(text.length - 1) == $lf) { + return text.length == 1 + ? 0 + : text.length - text.lastIndexOf('\n', text.length - 2) - 1; + } else { + return text.length - text.lastIndexOf('\n') - 1; + } + } + + /// Returns whether [span]'s text runs all the way to the end of its context. + static bool _isTextAtEndOfContext(SourceSpanWithContext span) => + findLineStart(span.context, span.text, span.start.column)! + + span.start.column + + span.length == + span.context.length; + + @override + String toString() { + final buffer = StringBuffer(); + if (isPrimary) buffer.write('primary '); + buffer.write('${span.start.line}:${span.start.column}-' + '${span.end.line}:${span.end.column}'); + if (label != null) buffer.write(' ($label)'); + return buffer.toString(); + } +} + +/// A single line of the source file being highlighted. +class _Line { + /// The text of the line, not including the trailing newline. + final String text; + + /// The 0-based line number in the source file. + final int number; + + /// The URL of the source file in which this line appears. + /// + /// For lines created from spans without an explicit URL, this is an opaque + /// object that differs between lines that come from different spans. + final Object url; + + /// All highlights that cover any portion of this line, in source span order. + /// + /// This is populated after the initial line is created. + final highlights = <_Highlight>[]; + + _Line(this.text, this.number, this.url); + + @override + String toString() => '$number: "$text" (${highlights.join(', ')})'; +} diff --git a/pkgs/source_span/lib/src/location.dart b/pkgs/source_span/lib/src/location.dart new file mode 100644 index 000000000..8f22d7ba2 --- /dev/null +++ b/pkgs/source_span/lib/src/location.dart @@ -0,0 +1,102 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'span.dart'; + +// TODO(nweiz): Use SourceLocationMixin once we decide to cut a release with +// breaking changes. See SourceLocationMixin for details. + +/// A class that describes a single location within a source file. +/// +/// This class should not be extended. Instead, [SourceLocationBase] should be +/// extended instead. +class SourceLocation implements Comparable { + /// URL of the source containing this location. + /// + /// This may be null, indicating that the source URL is unknown or + /// unavailable. + final Uri? sourceUrl; + + /// The 0-based offset of this location in the source. + final int offset; + + /// The 0-based line of this location in the source. + final int line; + + /// The 0-based column of this location in the source + final int column; + + /// Returns a representation of this location in the `source:line:column` + /// format used by text editors. + /// + /// This prints 1-based lines and columns. + String get toolString { + final source = sourceUrl ?? 'unknown source'; + return '$source:${line + 1}:${column + 1}'; + } + + /// Creates a new location indicating [offset] within [sourceUrl]. + /// + /// [line] and [column] default to assuming the source is a single line. This + /// means that [line] defaults to 0 and [column] defaults to [offset]. + /// + /// [sourceUrl] may be either a [String], a [Uri], or `null`. + SourceLocation(this.offset, {Object? sourceUrl, int? line, int? column}) + : sourceUrl = + sourceUrl is String ? Uri.parse(sourceUrl) : sourceUrl as Uri?, + line = line ?? 0, + column = column ?? offset { + if (offset < 0) { + throw RangeError('Offset may not be negative, was $offset.'); + } else if (line != null && line < 0) { + throw RangeError('Line may not be negative, was $line.'); + } else if (column != null && column < 0) { + throw RangeError('Column may not be negative, was $column.'); + } + } + + /// Returns the distance in characters between `this` and [other]. + /// + /// This always returns a non-negative value. + int distance(SourceLocation other) { + if (sourceUrl != other.sourceUrl) { + throw ArgumentError('Source URLs "$sourceUrl" and ' + "\"${other.sourceUrl}\" don't match."); + } + return (offset - other.offset).abs(); + } + + /// Returns a span that covers only a single point: this location. + SourceSpan pointSpan() => SourceSpan(this, this, ''); + + /// Compares two locations. + /// + /// [other] must have the same source URL as `this`. + @override + int compareTo(SourceLocation other) { + if (sourceUrl != other.sourceUrl) { + throw ArgumentError('Source URLs "$sourceUrl" and ' + "\"${other.sourceUrl}\" don't match."); + } + return offset - other.offset; + } + + @override + bool operator ==(Object other) => + other is SourceLocation && + sourceUrl == other.sourceUrl && + offset == other.offset; + + @override + int get hashCode => (sourceUrl?.hashCode ?? 0) + offset; + + @override + String toString() => '<$runtimeType: $offset $toolString>'; +} + +/// A base class for source locations with [offset], [line], and [column] known +/// at construction time. +class SourceLocationBase extends SourceLocation { + SourceLocationBase(super.offset, {super.sourceUrl, super.line, super.column}); +} diff --git a/pkgs/source_span/lib/src/location_mixin.dart b/pkgs/source_span/lib/src/location_mixin.dart new file mode 100644 index 000000000..a44f5e295 --- /dev/null +++ b/pkgs/source_span/lib/src/location_mixin.dart @@ -0,0 +1,55 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'location.dart'; +import 'span.dart'; + +// Note: this class duplicates a lot of functionality of [SourceLocation]. This +// is because in order for SourceLocation to use SourceLocationMixin, +// SourceLocationMixin couldn't implement SourceLocation. In SourceSpan we +// handle this by making the class itself non-extensible, but that would be a +// breaking change for SourceLocation. So until we want to endure the pain of +// cutting a release with breaking changes, we duplicate the code here. + +/// A mixin for easily implementing [SourceLocation]. +abstract class SourceLocationMixin implements SourceLocation { + @override + String get toolString { + final source = sourceUrl ?? 'unknown source'; + return '$source:${line + 1}:${column + 1}'; + } + + @override + int distance(SourceLocation other) { + if (sourceUrl != other.sourceUrl) { + throw ArgumentError('Source URLs "$sourceUrl" and ' + "\"${other.sourceUrl}\" don't match."); + } + return (offset - other.offset).abs(); + } + + @override + SourceSpan pointSpan() => SourceSpan(this, this, ''); + + @override + int compareTo(SourceLocation other) { + if (sourceUrl != other.sourceUrl) { + throw ArgumentError('Source URLs "$sourceUrl" and ' + "\"${other.sourceUrl}\" don't match."); + } + return offset - other.offset; + } + + @override + bool operator ==(Object other) => + other is SourceLocation && + sourceUrl == other.sourceUrl && + offset == other.offset; + + @override + int get hashCode => (sourceUrl?.hashCode ?? 0) + offset; + + @override + String toString() => '<$runtimeType: $offset $toolString>'; +} diff --git a/pkgs/source_span/lib/src/span.dart b/pkgs/source_span/lib/src/span.dart new file mode 100644 index 000000000..941dedce1 --- /dev/null +++ b/pkgs/source_span/lib/src/span.dart @@ -0,0 +1,193 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:path/path.dart' as p; +import 'package:term_glyph/term_glyph.dart' as glyph; + +import 'file.dart'; +import 'highlighter.dart'; +import 'location.dart'; +import 'span_mixin.dart'; +import 'span_with_context.dart'; +import 'utils.dart'; + +/// A class that describes a segment of source text. +abstract class SourceSpan implements Comparable { + /// The start location of this span. + SourceLocation get start; + + /// The end location of this span, exclusive. + SourceLocation get end; + + /// The source text for this span. + String get text; + + /// The URL of the source (typically a file) of this span. + /// + /// This may be null, indicating that the source URL is unknown or + /// unavailable. + Uri? get sourceUrl; + + /// The length of this span, in characters. + int get length; + + /// Creates a new span from [start] to [end] (exclusive) containing [text]. + /// + /// [start] and [end] must have the same source URL and [start] must come + /// before [end]. [text] must have a number of characters equal to the + /// distance between [start] and [end]. + factory SourceSpan(SourceLocation start, SourceLocation end, String text) => + SourceSpanBase(start, end, text); + + /// Creates a new span that's the union of `this` and [other]. + /// + /// The two spans must have the same source URL and may not be disjoint. + /// [text] is computed by combining `this.text` and `other.text`. + SourceSpan union(SourceSpan other); + + /// Compares two spans. + /// + /// [other] must have the same source URL as `this`. This orders spans by + /// [start] then [length]. + @override + int compareTo(SourceSpan other); + + /// Formats [message] in a human-friendly way associated with this span. + /// + /// [color] may either be a [String], a [bool], or `null`. If it's a string, + /// it indicates an [ANSI terminal color escape][] that should + /// be used to highlight the span's text (for example, `"\u001b[31m"` will + /// color red). If it's `true`, it indicates that the text should be + /// highlighted using the default color. If it's `false` or `null`, it + /// indicates that the text shouldn't be highlighted. + /// + /// This uses the full range of Unicode characters to highlight the source + /// span if [glyph.ascii] is `false` (the default), but only uses ASCII + /// characters if it's `true`. + /// + /// [ANSI terminal color escape]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors + String message(String message, {Object? color}); + + /// Prints the text associated with this span in a user-friendly way. + /// + /// This is identical to [message], except that it doesn't print the file + /// name, line number, column number, or message. If [length] is 0 and this + /// isn't a [SourceSpanWithContext], returns an empty string. + /// + /// [color] may either be a [String], a [bool], or `null`. If it's a string, + /// it indicates an [ANSI terminal color escape][] that should + /// be used to highlight the span's text (for example, `"\u001b[31m"` will + /// color red). If it's `true`, it indicates that the text should be + /// highlighted using the default color. If it's `false` or `null`, it + /// indicates that the text shouldn't be highlighted. + /// + /// This uses the full range of Unicode characters to highlight the source + /// span if [glyph.ascii] is `false` (the default), but only uses ASCII + /// characters if it's `true`. + /// + /// [ANSI terminal color escape]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors + String highlight({Object? color}); +} + +/// A base class for source spans with [start], [end], and [text] known at +/// construction time. +class SourceSpanBase extends SourceSpanMixin { + @override + final SourceLocation start; + @override + final SourceLocation end; + @override + final String text; + + SourceSpanBase(this.start, this.end, this.text) { + if (end.sourceUrl != start.sourceUrl) { + throw ArgumentError('Source URLs "${start.sourceUrl}" and ' + " \"${end.sourceUrl}\" don't match."); + } else if (end.offset < start.offset) { + throw ArgumentError('End $end must come after start $start.'); + } else if (text.length != start.distance(end)) { + throw ArgumentError('Text "$text" must be ${start.distance(end)} ' + 'characters long.'); + } + } +} + +// TODO(#52): Move these to instance methods in the next breaking release. +/// Extension methods on the base [SourceSpan] API. +extension SourceSpanExtension on SourceSpan { + /// Like [SourceSpan.message], but also highlights [secondarySpans] to provide + /// the user with additional context. + /// + /// Each span takes a label ([label] for this span, and the values of the + /// [secondarySpans] map for the secondary spans) that's used to indicate to + /// the user what that particular span represents. + /// + /// If [color] is `true`, [ANSI terminal color escapes][] are used to color + /// the resulting string. By default this span is colored red and the + /// secondary spans are colored blue, but that can be customized by passing + /// ANSI escape strings to [primaryColor] or [secondaryColor]. + /// + /// [ANSI terminal color escapes]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors + /// + /// Each span in [secondarySpans] must refer to the same document as this + /// span. Throws an [ArgumentError] if any secondary span has a different + /// source URL than this span. + /// + /// Note that while this will work with plain [SourceSpan]s, it will produce + /// much more useful output with [SourceSpanWithContext]s (including + /// [FileSpan]s). + String messageMultiple( + String message, String label, Map secondarySpans, + {bool color = false, String? primaryColor, String? secondaryColor}) { + final buffer = StringBuffer() + ..write('line ${start.line + 1}, column ${start.column + 1}'); + if (sourceUrl != null) buffer.write(' of ${p.prettyUri(sourceUrl)}'); + buffer + ..writeln(': $message') + ..write(highlightMultiple(label, secondarySpans, + color: color, + primaryColor: primaryColor, + secondaryColor: secondaryColor)); + return buffer.toString(); + } + + /// Like [SourceSpan.highlight], but also highlights [secondarySpans] to + /// provide the user with additional context. + /// + /// Each span takes a label ([label] for this span, and the values of the + /// [secondarySpans] map for the secondary spans) that's used to indicate to + /// the user what that particular span represents. + /// + /// If [color] is `true`, [ANSI terminal color escapes][] are used to color + /// the resulting string. By default this span is colored red and the + /// secondary spans are colored blue, but that can be customized by passing + /// ANSI escape strings to [primaryColor] or [secondaryColor]. + /// + /// [ANSI terminal color escapes]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors + /// + /// Each span in [secondarySpans] must refer to the same document as this + /// span. Throws an [ArgumentError] if any secondary span has a different + /// source URL than this span. + /// + /// Note that while this will work with plain [SourceSpan]s, it will produce + /// much more useful output with [SourceSpanWithContext]s (including + /// [FileSpan]s). + String highlightMultiple(String label, Map secondarySpans, + {bool color = false, String? primaryColor, String? secondaryColor}) => + Highlighter.multiple(this, label, secondarySpans, + color: color, + primaryColor: primaryColor, + secondaryColor: secondaryColor) + .highlight(); + + /// Returns a span from [start] code units (inclusive) to [end] code units + /// (exclusive) after the beginning of this span. + SourceSpan subspan(int start, [int? end]) { + RangeError.checkValidRange(start, end, length); + if (start == 0 && (end == null || end == length)) return this; + + final locations = subspanLocations(this, start, end); + return SourceSpan(locations[0], locations[1], text.substring(start, end)); + } +} diff --git a/pkgs/source_span/lib/src/span_exception.dart b/pkgs/source_span/lib/src/span_exception.dart new file mode 100644 index 000000000..90ad690d3 --- /dev/null +++ b/pkgs/source_span/lib/src/span_exception.dart @@ -0,0 +1,114 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'span.dart'; + +/// A class for exceptions that have source span information attached. +class SourceSpanException implements Exception { + // This is a getter so that subclasses can override it. + /// A message describing the exception. + String get message => _message; + final String _message; + + // This is a getter so that subclasses can override it. + /// The span associated with this exception. + /// + /// This may be `null` if the source location can't be determined. + SourceSpan? get span => _span; + final SourceSpan? _span; + + SourceSpanException(this._message, this._span); + + /// Returns a string representation of `this`. + /// + /// [color] may either be a [String], a [bool], or `null`. If it's a string, + /// it indicates an ANSI terminal color escape that should be used to + /// highlight the span's text. If it's `true`, it indicates that the text + /// should be highlighted using the default color. If it's `false` or `null`, + /// it indicates that the text shouldn't be highlighted. + @override + String toString({Object? color}) { + if (span == null) return message; + return 'Error on ${span!.message(message, color: color)}'; + } +} + +/// A [SourceSpanException] that's also a [FormatException]. +class SourceSpanFormatException extends SourceSpanException + implements FormatException { + @override + final dynamic source; + + @override + int? get offset => span?.start.offset; + + SourceSpanFormatException(super.message, super.span, [this.source]); +} + +/// A [SourceSpanException] that also highlights some secondary spans to provide +/// the user with extra context. +/// +/// Each span has a label ([primaryLabel] for the primary, and the values of the +/// [secondarySpans] map for the secondary spans) that's used to indicate to the +/// user what that particular span represents. +class MultiSourceSpanException extends SourceSpanException { + /// A label to attach to [span] that provides additional information and helps + /// distinguish it from [secondarySpans]. + final String primaryLabel; + + /// A map whose keys are secondary spans that should be highlighted. + /// + /// Each span's value is a label to attach to that span that provides + /// additional information and helps distinguish it from [secondarySpans]. + final Map secondarySpans; + + MultiSourceSpanException(super.message, super.span, this.primaryLabel, + Map secondarySpans) + : secondarySpans = Map.unmodifiable(secondarySpans); + + /// Returns a string representation of `this`. + /// + /// [color] may either be a [String], a [bool], or `null`. If it's a string, + /// it indicates an ANSI terminal color escape that should be used to + /// highlight the primary span's text. If it's `true`, it indicates that the + /// text should be highlighted using the default color. If it's `false` or + /// `null`, it indicates that the text shouldn't be highlighted. + /// + /// If [color] is `true` or a string, [secondaryColor] is used to highlight + /// [secondarySpans]. + @override + String toString({Object? color, String? secondaryColor}) { + if (span == null) return message; + + var useColor = false; + String? primaryColor; + if (color is String) { + useColor = true; + primaryColor = color; + } else if (color == true) { + useColor = true; + } + + final formatted = span!.messageMultiple( + message, primaryLabel, secondarySpans, + color: useColor, + primaryColor: primaryColor, + secondaryColor: secondaryColor); + return 'Error on $formatted'; + } +} + +/// A [MultiSourceSpanException] that's also a [FormatException]. +class MultiSourceSpanFormatException extends MultiSourceSpanException + implements FormatException { + @override + final dynamic source; + + @override + int? get offset => span?.start.offset; + + MultiSourceSpanFormatException( + super.message, super.span, super.primaryLabel, super.secondarySpans, + [this.source]); +} diff --git a/pkgs/source_span/lib/src/span_mixin.dart b/pkgs/source_span/lib/src/span_mixin.dart new file mode 100644 index 000000000..29b6119d2 --- /dev/null +++ b/pkgs/source_span/lib/src/span_mixin.dart @@ -0,0 +1,84 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:path/path.dart' as p; + +import 'highlighter.dart'; +import 'span.dart'; +import 'span_with_context.dart'; +import 'utils.dart'; + +/// A mixin for easily implementing [SourceSpan]. +/// +/// This implements the [SourceSpan] methods in terms of [start], [end], and +/// [text]. This assumes that [start] and [end] have the same source URL, that +/// [start] comes before [end], and that [text] has a number of characters equal +/// to the distance between [start] and [end]. +abstract class SourceSpanMixin implements SourceSpan { + @override + Uri? get sourceUrl => start.sourceUrl; + + @override + int get length => end.offset - start.offset; + + @override + int compareTo(SourceSpan other) { + final result = start.compareTo(other.start); + return result == 0 ? end.compareTo(other.end) : result; + } + + @override + SourceSpan union(SourceSpan other) { + if (sourceUrl != other.sourceUrl) { + throw ArgumentError('Source URLs "$sourceUrl" and ' + " \"${other.sourceUrl}\" don't match."); + } + + final start = min(this.start, other.start); + final end = max(this.end, other.end); + final beginSpan = start == this.start ? this : other; + final endSpan = end == this.end ? this : other; + + if (beginSpan.end.compareTo(endSpan.start) < 0) { + throw ArgumentError('Spans $this and $other are disjoint.'); + } + + final text = beginSpan.text + + endSpan.text.substring(beginSpan.end.distance(endSpan.start)); + return SourceSpan(start, end, text); + } + + @override + String message(String message, {Object? color}) { + final buffer = StringBuffer() + ..write('line ${start.line + 1}, column ${start.column + 1}'); + if (sourceUrl != null) buffer.write(' of ${p.prettyUri(sourceUrl)}'); + buffer.write(': $message'); + + final highlight = this.highlight(color: color); + if (highlight.isNotEmpty) { + buffer + ..writeln() + ..write(highlight); + } + + return buffer.toString(); + } + + @override + String highlight({Object? color}) { + if (this is! SourceSpanWithContext && length == 0) return ''; + return Highlighter(this, color: color).highlight(); + } + + @override + bool operator ==(Object other) => + other is SourceSpan && start == other.start && end == other.end; + + @override + int get hashCode => Object.hash(start, end); + + @override + String toString() => '<$runtimeType: from $start to $end "$text">'; +} diff --git a/pkgs/source_span/lib/src/span_with_context.dart b/pkgs/source_span/lib/src/span_with_context.dart new file mode 100644 index 000000000..776c78988 --- /dev/null +++ b/pkgs/source_span/lib/src/span_with_context.dart @@ -0,0 +1,51 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'location.dart'; +import 'span.dart'; +import 'utils.dart'; + +/// A class that describes a segment of source text with additional context. +class SourceSpanWithContext extends SourceSpanBase { + // This is a getter so that subclasses can override it. + /// Text around the span, which includes the line containing this span. + String get context => _context; + final String _context; + + /// Creates a new span from [start] to [end] (exclusive) containing [text], in + /// the given [context]. + /// + /// [start] and [end] must have the same source URL and [start] must come + /// before [end]. [text] must have a number of characters equal to the + /// distance between [start] and [end]. [context] must contain [text], and + /// [text] should start at `start.column` from the beginning of a line in + /// [context]. + SourceSpanWithContext( + SourceLocation start, SourceLocation end, String text, this._context) + : super(start, end, text) { + if (!context.contains(text)) { + throw ArgumentError('The context line "$context" must contain "$text".'); + } + + if (findLineStart(context, text, start.column) == null) { + throw ArgumentError('The span text "$text" must start at ' + 'column ${start.column + 1} in a line within "$context".'); + } + } +} + +// TODO(#52): Move these to instance methods in the next breaking release. +/// Extension methods on the base [SourceSpan] API. +extension SourceSpanWithContextExtension on SourceSpanWithContext { + /// Returns a span from [start] code units (inclusive) to [end] code units + /// (exclusive) after the beginning of this span. + SourceSpanWithContext subspan(int start, [int? end]) { + RangeError.checkValidRange(start, end, length); + if (start == 0 && (end == null || end == length)) return this; + + final locations = subspanLocations(this, start, end); + return SourceSpanWithContext( + locations[0], locations[1], text.substring(start, end), context); + } +} diff --git a/pkgs/source_span/lib/src/utils.dart b/pkgs/source_span/lib/src/utils.dart new file mode 100644 index 000000000..aba14ecf9 --- /dev/null +++ b/pkgs/source_span/lib/src/utils.dart @@ -0,0 +1,145 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'charcode.dart'; +import 'location.dart'; +import 'span.dart'; +import 'span_with_context.dart'; + +/// Returns the minimum of [obj1] and [obj2] according to +/// [Comparable.compareTo]. +T min>(T obj1, T obj2) => + obj1.compareTo(obj2) > 0 ? obj2 : obj1; + +/// Returns the maximum of [obj1] and [obj2] according to +/// [Comparable.compareTo]. +T max>(T obj1, T obj2) => + obj1.compareTo(obj2) > 0 ? obj1 : obj2; + +/// Returns whether all elements of [iter] are the same value, according to +/// `==`. +bool isAllTheSame(Iterable iter) { + if (iter.isEmpty) return true; + final firstValue = iter.first; + for (var value in iter.skip(1)) { + if (value != firstValue) { + return false; + } + } + return true; +} + +/// Returns whether [span] covers multiple lines. +bool isMultiline(SourceSpan span) => span.start.line != span.end.line; + +/// Sets the first `null` element of [list] to [element]. +void replaceFirstNull(List list, E element) { + final index = list.indexOf(null); + if (index < 0) throw ArgumentError('$list contains no null elements.'); + list[index] = element; +} + +/// Sets the element of [list] that currently contains [element] to `null`. +void replaceWithNull(List list, E element) { + final index = list.indexOf(element); + if (index < 0) { + throw ArgumentError('$list contains no elements matching $element.'); + } + + list[index] = null; +} + +/// Returns the number of instances of [codeUnit] in [string]. +int countCodeUnits(String string, int codeUnit) { + var count = 0; + for (var codeUnitToCheck in string.codeUnits) { + if (codeUnitToCheck == codeUnit) count++; + } + return count; +} + +/// Finds a line in [context] containing [text] at the specified [column]. +/// +/// Returns the index in [context] where that line begins, or null if none +/// exists. +int? findLineStart(String context, String text, int column) { + // If the text is empty, we just want to find the first line that has at least + // [column] characters. + if (text.isEmpty) { + var beginningOfLine = 0; + while (true) { + final index = context.indexOf('\n', beginningOfLine); + if (index == -1) { + return context.length - beginningOfLine >= column + ? beginningOfLine + : null; + } + + if (index - beginningOfLine >= column) return beginningOfLine; + beginningOfLine = index + 1; + } + } + + var index = context.indexOf(text); + while (index != -1) { + // Start looking before [index] in case [text] starts with a newline. + final lineStart = index == 0 ? 0 : context.lastIndexOf('\n', index - 1) + 1; + final textColumn = index - lineStart; + if (column == textColumn) return lineStart; + index = context.indexOf(text, index + 1); + } + // ignore: avoid_returning_null + return null; +} + +/// Returns a two-element list containing the start and end locations of the +/// span from [start] code units (inclusive) to [end] code units (exclusive) +/// after the beginning of [span]. +/// +/// This is factored out so it can be shared between +/// [SourceSpanExtension.subspan] and [SourceSpanWithContextExtension.subspan]. +List subspanLocations(SourceSpan span, int start, [int? end]) { + final text = span.text; + final startLocation = span.start; + var line = startLocation.line; + var column = startLocation.column; + + // Adjust [line] and [column] as necessary if the character at [i] in [text] + // is a newline. + void consumeCodePoint(int i) { + final codeUnit = text.codeUnitAt(i); + if (codeUnit == $lf || + // A carriage return counts as a newline, but only if it's not + // followed by a line feed. + (codeUnit == $cr && + (i + 1 == text.length || text.codeUnitAt(i + 1) != $lf))) { + line += 1; + column = 0; + } else { + column += 1; + } + } + + for (var i = 0; i < start; i++) { + consumeCodePoint(i); + } + + final newStartLocation = SourceLocation(startLocation.offset + start, + sourceUrl: span.sourceUrl, line: line, column: column); + + SourceLocation newEndLocation; + if (end == null || end == span.length) { + newEndLocation = span.end; + } else if (end == start) { + newEndLocation = newStartLocation; + } else { + for (var i = start; i < end; i++) { + consumeCodePoint(i); + } + newEndLocation = SourceLocation(startLocation.offset + end, + sourceUrl: span.sourceUrl, line: line, column: column); + } + + return [newStartLocation, newEndLocation]; +} diff --git a/pkgs/source_span/pubspec.yaml b/pkgs/source_span/pubspec.yaml new file mode 100644 index 000000000..8757b2dee --- /dev/null +++ b/pkgs/source_span/pubspec.yaml @@ -0,0 +1,17 @@ +name: source_span +version: 1.10.1 +description: >- + Provides a standard representation for source code locations and spans. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/source_span + +environment: + sdk: ^3.1.0 + +dependencies: + collection: ^1.15.0 + path: ^1.8.0 + term_glyph: ^1.2.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + test: ^1.16.0 diff --git a/pkgs/source_span/test/file_test.dart b/pkgs/source_span/test/file_test.dart new file mode 100644 index 000000000..dff51ee28 --- /dev/null +++ b/pkgs/source_span/test/file_test.dart @@ -0,0 +1,530 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:source_span/source_span.dart'; +import 'package:test/test.dart'; + +void main() { + late SourceFile file; + setUp(() { + file = SourceFile.fromString(''' +foo bar baz +whiz bang boom +zip zap zop''', url: 'foo.dart'); + }); + + group('errors', () { + group('for span()', () { + test('end must come after start', () { + expect(() => file.span(10, 5), throwsArgumentError); + }); + + test('start may not be negative', () { + expect(() => file.span(-1, 5), throwsRangeError); + }); + + test('end may not be outside the file', () { + expect(() => file.span(10, 100), throwsRangeError); + }); + }); + + group('for location()', () { + test('offset may not be negative', () { + expect(() => file.location(-1), throwsRangeError); + }); + + test('offset may not be outside the file', () { + expect(() => file.location(100), throwsRangeError); + }); + }); + + group('for getLine()', () { + test('offset may not be negative', () { + expect(() => file.getLine(-1), throwsRangeError); + }); + + test('offset may not be outside the file', () { + expect(() => file.getLine(100), throwsRangeError); + }); + }); + + group('for getColumn()', () { + test('offset may not be negative', () { + expect(() => file.getColumn(-1), throwsRangeError); + }); + + test('offset may not be outside the file', () { + expect(() => file.getColumn(100), throwsRangeError); + }); + + test('line may not be negative', () { + expect(() => file.getColumn(1, line: -1), throwsRangeError); + }); + + test('line may not be outside the file', () { + expect(() => file.getColumn(1, line: 100), throwsRangeError); + }); + + test('line must be accurate', () { + expect(() => file.getColumn(1, line: 1), throwsRangeError); + }); + }); + + group('getOffset()', () { + test('line may not be negative', () { + expect(() => file.getOffset(-1), throwsRangeError); + }); + + test('column may not be negative', () { + expect(() => file.getOffset(1, -1), throwsRangeError); + }); + + test('line may not be outside the file', () { + expect(() => file.getOffset(100), throwsRangeError); + }); + + test('column may not be outside the file', () { + expect(() => file.getOffset(2, 100), throwsRangeError); + }); + + test('column may not be outside the line', () { + expect(() => file.getOffset(1, 20), throwsRangeError); + }); + }); + + group('for getText()', () { + test('end must come after start', () { + expect(() => file.getText(10, 5), throwsArgumentError); + }); + + test('start may not be negative', () { + expect(() => file.getText(-1, 5), throwsRangeError); + }); + + test('end may not be outside the file', () { + expect(() => file.getText(10, 100), throwsRangeError); + }); + }); + + group('for span().union()', () { + test('source URLs must match', () { + final other = SourceSpan(SourceLocation(10), SourceLocation(11), '_'); + + expect(() => file.span(9, 10).union(other), throwsArgumentError); + }); + + test('spans may not be disjoint', () { + expect(() => file.span(9, 10).union(file.span(11, 12)), + throwsArgumentError); + }); + }); + + test('for span().expand() source URLs must match', () { + final other = SourceFile.fromString(''' +foo bar baz +whiz bang boom +zip zap zop''', url: 'bar.dart').span(10, 11); + + expect(() => file.span(9, 10).expand(other), throwsArgumentError); + }); + }); + + test('fields work correctly', () { + expect(file.url, equals(Uri.parse('foo.dart'))); + expect(file.lines, equals(3)); + expect(file.length, equals(38)); + }); + + group('new SourceFile()', () { + test('handles CRLF correctly', () { + expect(SourceFile.fromString('foo\r\nbar').getLine(6), equals(1)); + }); + + test('handles a lone CR correctly', () { + expect(SourceFile.fromString('foo\rbar').getLine(5), equals(1)); + }); + }); + + group('span()', () { + test('returns a span between the given offsets', () { + final span = file.span(5, 10); + expect(span.start, equals(file.location(5))); + expect(span.end, equals(file.location(10))); + }); + + test('end defaults to the end of the file', () { + final span = file.span(5); + expect(span.start, equals(file.location(5))); + expect(span.end, equals(file.location(file.length))); + }); + }); + + group('getLine()', () { + test('works for a middle character on the line', () { + expect(file.getLine(15), equals(1)); + }); + + test('works for the first character of a line', () { + expect(file.getLine(12), equals(1)); + }); + + test('works for a newline character', () { + expect(file.getLine(11), equals(0)); + }); + + test('works for the last offset', () { + expect(file.getLine(file.length), equals(2)); + }); + }); + + group('getColumn()', () { + test('works for a middle character on the line', () { + expect(file.getColumn(15), equals(3)); + }); + + test('works for the first character of a line', () { + expect(file.getColumn(12), equals(0)); + }); + + test('works for a newline character', () { + expect(file.getColumn(11), equals(11)); + }); + + test('works when line is passed as well', () { + expect(file.getColumn(12, line: 1), equals(0)); + }); + + test('works for the last offset', () { + expect(file.getColumn(file.length), equals(11)); + }); + }); + + group('getOffset()', () { + test('works for a middle character on the line', () { + expect(file.getOffset(1, 3), equals(15)); + }); + + test('works for the first character of a line', () { + expect(file.getOffset(1), equals(12)); + }); + + test('works for a newline character', () { + expect(file.getOffset(0, 11), equals(11)); + }); + + test('works for the last offset', () { + expect(file.getOffset(2, 11), equals(file.length)); + }); + }); + + group('getText()', () { + test('returns a substring of the source', () { + expect(file.getText(8, 15), equals('baz\nwhi')); + }); + + test('end defaults to the end of the file', () { + expect(file.getText(20), equals('g boom\nzip zap zop')); + }); + }); + + group('FileLocation', () { + test('reports the correct line number', () { + expect(file.location(15).line, equals(1)); + }); + + test('reports the correct column number', () { + expect(file.location(15).column, equals(3)); + }); + + test('pointSpan() returns a FileSpan', () { + final location = file.location(15); + final span = location.pointSpan(); + expect(span, isA()); + expect(span.start, equals(location)); + expect(span.end, equals(location)); + expect(span.text, isEmpty); + }); + }); + + group('FileSpan', () { + test('text returns a substring of the source', () { + expect(file.span(8, 15).text, equals('baz\nwhi')); + }); + + test('text includes the last char when end is defaulted to EOF', () { + expect(file.span(29).text, equals('p zap zop')); + }); + + group('context', () { + test("contains the span's text", () { + final span = file.span(8, 15); + expect(span.context.contains(span.text), isTrue); + expect(span.context, equals('foo bar baz\nwhiz bang boom\n')); + }); + + test('contains the previous line for a point span at the end of a line', + () { + final span = file.span(25, 25); + expect(span.context, equals('whiz bang boom\n')); + }); + + test('contains the next line for a point span at the beginning of a line', + () { + final span = file.span(12, 12); + expect(span.context, equals('whiz bang boom\n')); + }); + + group('for a point span at the end of a file', () { + test('without a newline, contains the last line', () { + final span = file.span(file.length, file.length); + expect(span.context, equals('zip zap zop')); + }); + + test('with a newline, contains an empty line', () { + file = SourceFile.fromString(''' +foo bar baz +whiz bang boom +zip zap zop +''', url: 'foo.dart'); + + final span = file.span(file.length, file.length); + expect(span.context, isEmpty); + }); + }); + }); + + group('union()', () { + late FileSpan span; + setUp(() { + span = file.span(5, 12); + }); + + test('works with a preceding adjacent span', () { + final other = file.span(0, 5); + final result = span.union(other); + expect(result.start, equals(other.start)); + expect(result.end, equals(span.end)); + expect(result.text, equals('foo bar baz\n')); + }); + + test('works with a preceding overlapping span', () { + final other = file.span(0, 8); + final result = span.union(other); + expect(result.start, equals(other.start)); + expect(result.end, equals(span.end)); + expect(result.text, equals('foo bar baz\n')); + }); + + test('works with a following adjacent span', () { + final other = file.span(12, 16); + final result = span.union(other); + expect(result.start, equals(span.start)); + expect(result.end, equals(other.end)); + expect(result.text, equals('ar baz\nwhiz')); + }); + + test('works with a following overlapping span', () { + final other = file.span(9, 16); + final result = span.union(other); + expect(result.start, equals(span.start)); + expect(result.end, equals(other.end)); + expect(result.text, equals('ar baz\nwhiz')); + }); + + test('works with an internal overlapping span', () { + final other = file.span(7, 10); + expect(span.union(other), equals(span)); + }); + + test('works with an external overlapping span', () { + final other = file.span(0, 16); + expect(span.union(other), equals(other)); + }); + + test('returns a FileSpan for a FileSpan input', () { + expect(span.union(file.span(0, 5)), isA()); + }); + + test('returns a base SourceSpan for a SourceSpan input', () { + final other = SourceSpan(SourceLocation(0, sourceUrl: 'foo.dart'), + SourceLocation(5, sourceUrl: 'foo.dart'), 'hey, '); + final result = span.union(other); + expect(result, isNot(isA())); + expect(result.start, equals(other.start)); + expect(result.end, equals(span.end)); + expect(result.text, equals('hey, ar baz\n')); + }); + }); + + group('expand()', () { + late FileSpan span; + setUp(() { + span = file.span(5, 12); + }); + + test('works with a preceding nonadjacent span', () { + final other = file.span(0, 3); + final result = span.expand(other); + expect(result.start, equals(other.start)); + expect(result.end, equals(span.end)); + expect(result.text, equals('foo bar baz\n')); + }); + + test('works with a preceding overlapping span', () { + final other = file.span(0, 8); + final result = span.expand(other); + expect(result.start, equals(other.start)); + expect(result.end, equals(span.end)); + expect(result.text, equals('foo bar baz\n')); + }); + + test('works with a following nonadjacent span', () { + final other = file.span(14, 16); + final result = span.expand(other); + expect(result.start, equals(span.start)); + expect(result.end, equals(other.end)); + expect(result.text, equals('ar baz\nwhiz')); + }); + + test('works with a following overlapping span', () { + final other = file.span(9, 16); + final result = span.expand(other); + expect(result.start, equals(span.start)); + expect(result.end, equals(other.end)); + expect(result.text, equals('ar baz\nwhiz')); + }); + + test('works with an internal overlapping span', () { + final other = file.span(7, 10); + expect(span.expand(other), equals(span)); + }); + + test('works with an external overlapping span', () { + final other = file.span(0, 16); + expect(span.expand(other), equals(other)); + }); + }); + + group('subspan()', () { + late FileSpan span; + setUp(() { + span = file.span(5, 11); // "ar baz" + }); + + group('errors', () { + test('start must be greater than zero', () { + expect(() => span.subspan(-1), throwsRangeError); + }); + + test('start must be less than or equal to length', () { + expect(() => span.subspan(span.length + 1), throwsRangeError); + }); + + test('end must be greater than start', () { + expect(() => span.subspan(2, 1), throwsRangeError); + }); + + test('end must be less than or equal to length', () { + expect(() => span.subspan(0, span.length + 1), throwsRangeError); + }); + }); + + test('preserves the source URL', () { + final result = span.subspan(1, 2); + expect(result.start.sourceUrl, equals(span.sourceUrl)); + expect(result.end.sourceUrl, equals(span.sourceUrl)); + }); + + group('returns the original span', () { + test('with an implicit end', + () => expect(span.subspan(0), equals(span))); + + test('with an explicit end', + () => expect(span.subspan(0, span.length), equals(span))); + }); + + group('within a single line', () { + test('returns a strict substring of the original span', () { + final result = span.subspan(1, 5); + expect(result.text, equals('r ba')); + expect(result.start.offset, equals(6)); + expect(result.start.line, equals(0)); + expect(result.start.column, equals(6)); + expect(result.end.offset, equals(10)); + expect(result.end.line, equals(0)); + expect(result.end.column, equals(10)); + }); + + test('an implicit end goes to the end of the original span', () { + final result = span.subspan(1); + expect(result.text, equals('r baz')); + expect(result.start.offset, equals(6)); + expect(result.start.line, equals(0)); + expect(result.start.column, equals(6)); + expect(result.end.offset, equals(11)); + expect(result.end.line, equals(0)); + expect(result.end.column, equals(11)); + }); + + test('can return an empty span', () { + final result = span.subspan(3, 3); + expect(result.text, isEmpty); + expect(result.start.offset, equals(8)); + expect(result.start.line, equals(0)); + expect(result.start.column, equals(8)); + expect(result.end, equals(result.start)); + }); + }); + + group('across multiple lines', () { + setUp(() { + span = file.span(22, 30); // "boom\nzip" + }); + + test('with start and end in the middle of a line', () { + final result = span.subspan(3, 6); + expect(result.text, equals('m\nz')); + expect(result.start.offset, equals(25)); + expect(result.start.line, equals(1)); + expect(result.start.column, equals(13)); + expect(result.end.offset, equals(28)); + expect(result.end.line, equals(2)); + expect(result.end.column, equals(1)); + }); + + test('with start at the end of a line', () { + final result = span.subspan(4, 6); + expect(result.text, equals('\nz')); + expect(result.start.offset, equals(26)); + expect(result.start.line, equals(1)); + expect(result.start.column, equals(14)); + }); + + test('with start at the beginning of a line', () { + final result = span.subspan(5, 6); + expect(result.text, equals('z')); + expect(result.start.offset, equals(27)); + expect(result.start.line, equals(2)); + expect(result.start.column, equals(0)); + }); + + test('with end at the end of a line', () { + final result = span.subspan(3, 4); + expect(result.text, equals('m')); + expect(result.end.offset, equals(26)); + expect(result.end.line, equals(1)); + expect(result.end.column, equals(14)); + }); + + test('with end at the beginning of a line', () { + final result = span.subspan(3, 5); + expect(result.text, equals('m\n')); + expect(result.end.offset, equals(27)); + expect(result.end.line, equals(2)); + expect(result.end.column, equals(0)); + }); + }); + }); + }); +} diff --git a/pkgs/source_span/test/highlight_test.dart b/pkgs/source_span/test/highlight_test.dart new file mode 100644 index 000000000..93c42dbaa --- /dev/null +++ b/pkgs/source_span/test/highlight_test.dart @@ -0,0 +1,605 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// ignore_for_file: prefer_interpolation_to_compose_strings + +import 'package:source_span/source_span.dart'; +import 'package:source_span/src/colors.dart' as colors; +import 'package:term_glyph/term_glyph.dart' as glyph; +import 'package:test/test.dart'; + +void main() { + late bool oldAscii; + setUpAll(() { + oldAscii = glyph.ascii; + glyph.ascii = true; + }); + + tearDownAll(() { + glyph.ascii = oldAscii; + }); + + late SourceFile file; + setUp(() { + file = SourceFile.fromString(''' +foo bar baz +whiz bang boom +zip zap zop +'''); + }); + + test('points to the span in the source', () { + expect(file.span(4, 7).highlight(), equals(""" + , +1 | foo bar baz + | ^^^ + '""")); + }); + + test('gracefully handles a missing source URL', () { + final span = SourceFile.fromString('foo bar baz').span(4, 7); + expect(span.highlight(), equals(""" + , +1 | foo bar baz + | ^^^ + '""")); + }); + + group('highlights a point span', () { + test('in the middle of a line', () { + expect(file.location(4).pointSpan().highlight(), equals(""" + , +1 | foo bar baz + | ^ + '""")); + }); + + test('at the beginning of the file', () { + expect(file.location(0).pointSpan().highlight(), equals(""" + , +1 | foo bar baz + | ^ + '""")); + }); + + test('at the beginning of a line', () { + expect(file.location(12).pointSpan().highlight(), equals(""" + , +2 | whiz bang boom + | ^ + '""")); + }); + + test('at the end of a line', () { + expect(file.location(11).pointSpan().highlight(), equals(""" + , +1 | foo bar baz + | ^ + '""")); + }); + + test('at the end of the file', () { + expect(file.location(38).pointSpan().highlight(), equals(""" + , +3 | zip zap zop + | ^ + '""")); + }); + + test('after the end of the file', () { + expect(file.location(39).pointSpan().highlight(), equals(""" + , +4 | + | ^ + '""")); + }); + + test('at the end of the file with no trailing newline', () { + file = SourceFile.fromString('zip zap zop'); + expect(file.location(10).pointSpan().highlight(), equals(""" + , +1 | zip zap zop + | ^ + '""")); + }); + + test('after the end of the file with no trailing newline', () { + file = SourceFile.fromString('zip zap zop'); + expect(file.location(11).pointSpan().highlight(), equals(""" + , +1 | zip zap zop + | ^ + '""")); + }); + + test('in an empty file', () { + expect(SourceFile.fromString('').location(0).pointSpan().highlight(), + equals(""" + , +1 | + | ^ + '""")); + }); + + test('on an empty line', () { + final file = SourceFile.fromString('foo\n\nbar'); + expect(file.location(4).pointSpan().highlight(), equals(""" + , +2 | + | ^ + '""")); + }); + }); + + test('highlights a single-line file without a newline', () { + expect(SourceFile.fromString('foo bar').span(0, 7).highlight(), equals(""" + , +1 | foo bar + | ^^^^^^^ + '""")); + }); + + test('highlights text including a trailing newline', () { + expect(file.span(8, 12).highlight(), equals(""" + , +1 | foo bar baz + | ^^^ + '""")); + }); + + test('highlights a single empty line', () { + expect( + SourceFile.fromString('foo\n\nbar').span(4, 5).highlight(), equals(""" + , +2 | + | ^ + '""")); + }); + + test('highlights a trailing newline', () { + expect(file.span(11, 12).highlight(), equals(""" + , +1 | foo bar baz + | ^ + '""")); + }); + + group('with a multiline span', () { + test('highlights the middle of the first and last lines', () { + expect(file.span(4, 34).highlight(), equals(""" + , +1 | foo bar baz + | ,-----^ +2 | | whiz bang boom +3 | | zip zap zop + | '-------^ + '""")); + }); + + test('works when it begins at the end of a line', () { + expect(file.span(11, 34).highlight(), equals(""" + , +1 | foo bar baz + | ,------------^ +2 | | whiz bang boom +3 | | zip zap zop + | '-------^ + '""")); + }); + + test('works when it ends at the beginning of a line', () { + expect(file.span(4, 28).highlight(), equals(""" + , +1 | foo bar baz + | ,-----^ +2 | | whiz bang boom +3 | | zip zap zop + | '-^ + '""")); + }); + + test('highlights the full first line', () { + expect(file.span(0, 34).highlight(), equals(""" + , +1 | / foo bar baz +2 | | whiz bang boom +3 | | zip zap zop + | '-------^ + '""")); + }); + + test("highlights the full first line even if it's indented", () { + final file = SourceFile.fromString(''' + foo bar baz + whiz bang boom + zip zap zop +'''); + + expect(file.span(2, 38).highlight(), equals(""" + , +1 | / foo bar baz +2 | | whiz bang boom +3 | | zip zap zop + | '-------^ + '""")); + }); + + test("highlights the full first line if it's empty", () { + final file = SourceFile.fromString(''' +foo + +bar +'''); + + expect(file.span(4, 9).highlight(), equals(r""" + , +2 | / +3 | \ bar + '""")); + }); + + test('highlights the full last line', () { + expect(file.span(4, 27).highlight(), equals(r""" + , +1 | foo bar baz + | ,-----^ +2 | \ whiz bang boom + '""")); + }); + + test('highlights the full last line with no trailing newline', () { + expect(file.span(4, 26).highlight(), equals(r""" + , +1 | foo bar baz + | ,-----^ +2 | \ whiz bang boom + '""")); + }); + + test('highlights the full last line with a trailing Windows newline', () { + final file = SourceFile.fromString(''' +foo bar baz\r +whiz bang boom\r +zip zap zop\r +'''); + + expect(file.span(4, 29).highlight(), equals(r""" + , +1 | foo bar baz + | ,-----^ +2 | \ whiz bang boom + '""")); + }); + + test('highlights the full last line at the end of the file', () { + expect(file.span(4, 39).highlight(), equals(r""" + , +1 | foo bar baz + | ,-----^ +2 | | whiz bang boom +3 | \ zip zap zop + '""")); + }); + + test( + 'highlights the full last line at the end of the file with no trailing ' + 'newline', () { + final file = SourceFile.fromString(''' +foo bar baz +whiz bang boom +zip zap zop'''); + + expect(file.span(4, 38).highlight(), equals(r""" + , +1 | foo bar baz + | ,-----^ +2 | | whiz bang boom +3 | \ zip zap zop + '""")); + }); + + test("highlights the full last line if it's empty", () { + final file = SourceFile.fromString(''' +foo + +bar +'''); + + expect(file.span(0, 5).highlight(), equals(r""" + , +1 | / foo +2 | \ + '""")); + }); + + test('highlights multiple empty lines', () { + final file = SourceFile.fromString('foo\n\n\n\nbar'); + expect(file.span(4, 7).highlight(), equals(r""" + , +2 | / +3 | | +4 | \ + '""")); + }); + + // Regression test for #32 + test('highlights the end of a line and an empty line', () { + final file = SourceFile.fromString('foo\n\n'); + expect(file.span(3, 5).highlight(), equals(r""" + , +1 | foo + | ,----^ +2 | \ + '""")); + }); + }); + + group('prints tabs as spaces', () { + group('in a single-line span', () { + test('before the highlighted section', () { + final span = SourceFile.fromString('foo\tbar baz').span(4, 7); + + expect(span.highlight(), equals(""" + , +1 | foo bar baz + | ^^^ + '""")); + }); + + test('within the highlighted section', () { + final span = SourceFile.fromString('foo bar\tbaz bang').span(4, 11); + + expect(span.highlight(), equals(""" + , +1 | foo bar baz bang + | ^^^^^^^^^^ + '""")); + }); + + test('after the highlighted section', () { + final span = SourceFile.fromString('foo bar\tbaz').span(4, 7); + + expect(span.highlight(), equals(""" + , +1 | foo bar baz + | ^^^ + '""")); + }); + }); + + group('in a multi-line span', () { + test('before the highlighted section', () { + final span = SourceFile.fromString(''' +foo\tbar baz +whiz bang boom +''').span(4, 21); + + expect(span.highlight(), equals(""" + , +1 | foo bar baz + | ,--------^ +2 | | whiz bang boom + | '---------^ + '""")); + }); + + test('within the first highlighted line', () { + final span = SourceFile.fromString(''' +foo bar\tbaz +whiz bang boom +''').span(4, 21); + + expect(span.highlight(), equals(""" + , +1 | foo bar baz + | ,-----^ +2 | | whiz bang boom + | '---------^ + '""")); + }); + + test('at the beginning of the first highlighted line', () { + final span = SourceFile.fromString(''' +foo bar\tbaz +whiz bang boom +''').span(7, 21); + + expect(span.highlight(), equals(""" + , +1 | foo bar baz + | ,--------^ +2 | | whiz bang boom + | '---------^ + '""")); + }); + + test('within a middle highlighted line', () { + final span = SourceFile.fromString(''' +foo bar baz +whiz\tbang boom +zip zap zop +''').span(4, 34); + + expect(span.highlight(), equals(""" + , +1 | foo bar baz + | ,-----^ +2 | | whiz bang boom +3 | | zip zap zop + | '-------^ + '""")); + }); + + test('within the last highlighted line', () { + final span = SourceFile.fromString(''' +foo bar baz +whiz\tbang boom +''').span(4, 21); + + expect(span.highlight(), equals(""" + , +1 | foo bar baz + | ,-----^ +2 | | whiz bang boom + | '------------^ + '""")); + }); + + test('at the end of the last highlighted line', () { + final span = SourceFile.fromString(''' +foo bar baz +whiz\tbang boom +''').span(4, 17); + + expect(span.highlight(), equals(""" + , +1 | foo bar baz + | ,-----^ +2 | | whiz bang boom + | '--------^ + '""")); + }); + + test('after the highlighted section', () { + final span = SourceFile.fromString(''' +foo bar baz +whiz bang\tboom +''').span(4, 21); + + expect(span.highlight(), equals(""" + , +1 | foo bar baz + | ,-----^ +2 | | whiz bang boom + | '---------^ + '""")); + }); + }); + }); + + group('supports lines of preceding and following context for a span', () { + test('within a single line', () { + final span = SourceSpanWithContext( + SourceLocation(20, line: 2, column: 5, sourceUrl: 'foo.dart'), + SourceLocation(27, line: 2, column: 12, sourceUrl: 'foo.dart'), + 'foo bar', + 'previous\nlines\n-----foo bar-----\nfollowing line\n'); + + expect(span.highlight(), equals(""" + , +1 | previous +2 | lines +3 | -----foo bar----- + | ^^^^^^^ +4 | following line + '""")); + }); + + test('covering a full line', () { + final span = SourceSpanWithContext( + SourceLocation(15, line: 2, column: 0, sourceUrl: 'foo.dart'), + SourceLocation(33, line: 3, column: 0, sourceUrl: 'foo.dart'), + '-----foo bar-----\n', + 'previous\nlines\n-----foo bar-----\nfollowing line\n'); + + expect(span.highlight(), equals(""" + , +1 | previous +2 | lines +3 | -----foo bar----- + | ^^^^^^^^^^^^^^^^^ +4 | following line + '""")); + }); + + test('covering multiple full lines', () { + final span = SourceSpanWithContext( + SourceLocation(15, line: 2, column: 0, sourceUrl: 'foo.dart'), + SourceLocation(23, line: 4, column: 0, sourceUrl: 'foo.dart'), + 'foo\nbar\n', + 'previous\nlines\nfoo\nbar\nfollowing line\n'); + + expect(span.highlight(), equals(r""" + , +1 | previous +2 | lines +3 | / foo +4 | \ bar +5 | following line + '""")); + }); + }); + + group('colors', () { + test("doesn't colorize if color is false", () { + expect(file.span(4, 7).highlight(color: false), equals(""" + , +1 | foo bar baz + | ^^^ + '""")); + }); + + test('colorizes if color is true', () { + expect(file.span(4, 7).highlight(color: true), equals(''' +${colors.blue} ,${colors.none} +${colors.blue}1 |${colors.none} foo ${colors.red}bar${colors.none} baz +${colors.blue} |${colors.none} ${colors.red} ^^^${colors.none} +${colors.blue} '${colors.none}''')); + }); + + test("uses the given color if it's passed", () { + expect(file.span(4, 7).highlight(color: colors.yellow), equals(''' +${colors.blue} ,${colors.none} +${colors.blue}1 |${colors.none} foo ${colors.yellow}bar${colors.none} baz +${colors.blue} |${colors.none} ${colors.yellow} ^^^${colors.none} +${colors.blue} '${colors.none}''')); + }); + + test('colorizes a multiline span', () { + expect(file.span(4, 34).highlight(color: true), equals(''' +${colors.blue} ,${colors.none} +${colors.blue}1 |${colors.none} foo ${colors.red}bar baz${colors.none} +${colors.blue} |${colors.none} ${colors.red},${colors.none}${colors.red}-----^${colors.none} +${colors.blue}2 |${colors.none} ${colors.red}|${colors.none} ${colors.red}whiz bang boom${colors.none} +${colors.blue}3 |${colors.none} ${colors.red}|${colors.none} ${colors.red}zip zap${colors.none} zop +${colors.blue} |${colors.none} ${colors.red}'${colors.none}${colors.red}-------^${colors.none} +${colors.blue} '${colors.none}''')); + }); + + test('colorizes a multiline span that highlights full lines', () { + expect(file.span(0, 39).highlight(color: true), equals(''' +${colors.blue} ,${colors.none} +${colors.blue}1 |${colors.none} ${colors.red}/${colors.none} ${colors.red}foo bar baz${colors.none} +${colors.blue}2 |${colors.none} ${colors.red}|${colors.none} ${colors.red}whiz bang boom${colors.none} +${colors.blue}3 |${colors.none} ${colors.red}\\${colors.none} ${colors.red}zip zap zop${colors.none} +${colors.blue} '${colors.none}''')); + }); + }); + + group('line numbers have appropriate padding', () { + test('with line number 9', () { + expect( + SourceFile.fromString('\n' * 8 + 'foo bar baz\n') + .span(8, 11) + .highlight(), + equals(""" + , +9 | foo bar baz + | ^^^ + '""")); + }); + + test('with line number 10', () { + expect( + SourceFile.fromString('\n' * 9 + 'foo bar baz\n') + .span(9, 12) + .highlight(), + equals(""" + , +10 | foo bar baz + | ^^^ + '""")); + }); + }); +} diff --git a/pkgs/source_span/test/location_test.dart b/pkgs/source_span/test/location_test.dart new file mode 100644 index 000000000..bbe259b12 --- /dev/null +++ b/pkgs/source_span/test/location_test.dart @@ -0,0 +1,97 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:source_span/source_span.dart'; +import 'package:test/test.dart'; + +void main() { + late SourceLocation location; + setUp(() { + location = SourceLocation(15, line: 2, column: 6, sourceUrl: 'foo.dart'); + }); + + group('errors', () { + group('for new SourceLocation()', () { + test('offset may not be negative', () { + expect(() => SourceLocation(-1), throwsRangeError); + }); + + test('line may not be negative', () { + expect(() => SourceLocation(0, line: -1), throwsRangeError); + }); + + test('column may not be negative', () { + expect(() => SourceLocation(0, column: -1), throwsRangeError); + }); + }); + + test('for distance() source URLs must match', () { + expect(() => location.distance(SourceLocation(0)), throwsArgumentError); + }); + + test('for compareTo() source URLs must match', () { + expect(() => location.compareTo(SourceLocation(0)), throwsArgumentError); + }); + }); + + test('fields work correctly', () { + expect(location.sourceUrl, equals(Uri.parse('foo.dart'))); + expect(location.offset, equals(15)); + expect(location.line, equals(2)); + expect(location.column, equals(6)); + }); + + group('toolString', () { + test('returns a computer-readable representation', () { + expect(location.toolString, equals('foo.dart:3:7')); + }); + + test('gracefully handles a missing source URL', () { + final location = SourceLocation(15, line: 2, column: 6); + expect(location.toolString, equals('unknown source:3:7')); + }); + }); + + test('distance returns the absolute distance between locations', () { + final other = SourceLocation(10, sourceUrl: 'foo.dart'); + expect(location.distance(other), equals(5)); + expect(other.distance(location), equals(5)); + }); + + test('pointSpan returns an empty span at location', () { + final span = location.pointSpan(); + expect(span.start, equals(location)); + expect(span.end, equals(location)); + expect(span.text, isEmpty); + }); + + group('compareTo()', () { + test('sorts by offset', () { + final other = SourceLocation(20, sourceUrl: 'foo.dart'); + expect(location.compareTo(other), lessThan(0)); + expect(other.compareTo(location), greaterThan(0)); + }); + + test('considers equal locations equal', () { + expect(location.compareTo(location), equals(0)); + }); + }); + + group('equality', () { + test('two locations with the same offset and source are equal', () { + final other = SourceLocation(15, sourceUrl: 'foo.dart'); + expect(location, equals(other)); + }); + + test("a different offset isn't equal", () { + final other = SourceLocation(10, sourceUrl: 'foo.dart'); + expect(location, isNot(equals(other))); + }); + + test("a different source isn't equal", () { + final other = SourceLocation(15, sourceUrl: 'bar.dart'); + expect(location, isNot(equals(other))); + }); + }); +} diff --git a/pkgs/source_span/test/multiple_highlight_test.dart b/pkgs/source_span/test/multiple_highlight_test.dart new file mode 100644 index 000000000..139d53c8a --- /dev/null +++ b/pkgs/source_span/test/multiple_highlight_test.dart @@ -0,0 +1,423 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:source_span/source_span.dart'; +import 'package:term_glyph/term_glyph.dart' as glyph; +import 'package:test/test.dart'; + +void main() { + late bool oldAscii; + setUpAll(() { + oldAscii = glyph.ascii; + glyph.ascii = true; + }); + + tearDownAll(() { + glyph.ascii = oldAscii; + }); + + late SourceFile file; + setUp(() { + file = SourceFile.fromString(''' +foo bar baz +whiz bang boom +zip zap zop +fwee fwoo fwip +argle bargle boo +gibble bibble bop +''', url: 'file1.txt'); + }); + + test('highlights spans on separate lines', () { + expect( + file.span(17, 21).highlightMultiple( + 'one', {file.span(31, 34): 'two', file.span(4, 7): 'three'}), + equals(""" + , +1 | foo bar baz + | === three +2 | whiz bang boom + | ^^^^ one +3 | zip zap zop + | === two + '""")); + }); + + test('highlights spans on the same line', () { + expect( + file.span(17, 21).highlightMultiple( + 'one', {file.span(22, 26): 'two', file.span(12, 16): 'three'}), + equals(""" + , +2 | whiz bang boom + | ^^^^ one + | ==== three + | ==== two + '""")); + }); + + test('highlights overlapping spans on the same line', () { + expect( + file.span(17, 21).highlightMultiple( + 'one', {file.span(20, 26): 'two', file.span(12, 18): 'three'}), + equals(""" + , +2 | whiz bang boom + | ^^^^ one + | ====== three + | ====== two + '""")); + }); + + test('highlights multiple multiline spans', () { + expect( + file.span(27, 54).highlightMultiple( + 'one', {file.span(54, 89): 'two', file.span(0, 27): 'three'}), + equals(""" + , +1 | / foo bar baz +2 | | whiz bang boom + | '--- three +3 | / zip zap zop +4 | | fwee fwoo fwip + | '--- one +5 | / argle bargle boo +6 | | gibble bibble bop + | '--- two + '""")); + }); + + test('highlights multiple overlapping multiline spans', () { + expect( + file.span(12, 70).highlightMultiple( + 'one', {file.span(54, 89): 'two', file.span(0, 27): 'three'}), + equals(""" + , +1 | /- foo bar baz +2 | |/ whiz bang boom + | '+--- three +3 | | zip zap zop +4 | | fwee fwoo fwip +5 | /+ argle bargle boo + | |'--- one +6 | | gibble bibble bop + | '---- two + '""")); + }); + + test('highlights many layers of overlaps', () { + expect( + file.span(0, 54).highlightMultiple('one', { + file.span(12, 77): 'two', + file.span(27, 84): 'three', + file.span(39, 88): 'four' + }), + equals(""" + , +1 | /--- foo bar baz +2 | |/-- whiz bang boom +3 | ||/- zip zap zop +4 | |||/ fwee fwoo fwip + | '+++--- one +5 | ||| argle bargle boo +6 | ||| gibble bibble bop + | '++------^ two + | '+-------------^ three + | '--- four + '""")); + }); + + group("highlights a multiline span that's a subset", () { + test('with no first or last line overlap', () { + expect( + file + .span(27, 53) + .highlightMultiple('inner', {file.span(12, 70): 'outer'}), + equals(""" + , +2 | /- whiz bang boom +3 | |/ zip zap zop +4 | || fwee fwoo fwip + | |'--- inner +5 | | argle bargle boo + | '---- outer + '""")); + }); + + test('overlapping the whole first line', () { + expect( + file + .span(12, 53) + .highlightMultiple('inner', {file.span(12, 70): 'outer'}), + equals(""" + , +2 | // whiz bang boom +3 | || zip zap zop +4 | || fwee fwoo fwip + | |'--- inner +5 | | argle bargle boo + | '---- outer + '""")); + }); + + test('overlapping part of first line', () { + expect( + file + .span(17, 53) + .highlightMultiple('inner', {file.span(12, 70): 'outer'}), + equals(""" + , +2 | /- whiz bang boom + | |,------^ +3 | || zip zap zop +4 | || fwee fwoo fwip + | |'--- inner +5 | | argle bargle boo + | '---- outer + '""")); + }); + + test('overlapping the whole last line', () { + expect( + file + .span(27, 70) + .highlightMultiple('inner', {file.span(12, 70): 'outer'}), + equals(""" + , +2 | /- whiz bang boom +3 | |/ zip zap zop +4 | || fwee fwoo fwip +5 | || argle bargle boo + | |'--- inner + | '---- outer + '""")); + }); + + test('overlapping part of the last line', () { + expect( + file + .span(27, 66) + .highlightMultiple('inner', {file.span(12, 70): 'outer'}), + equals(""" + , +2 | /- whiz bang boom +3 | |/ zip zap zop +4 | || fwee fwoo fwip +5 | || argle bargle boo + | |'------------^ inner + | '---- outer + '""")); + }); + }); + + group('a single-line span in a multiline span', () { + test('on the first line', () { + expect( + file + .span(17, 21) + .highlightMultiple('inner', {file.span(12, 70): 'outer'}), + equals(""" + , +2 | / whiz bang boom + | | ^^^^ inner +3 | | zip zap zop +4 | | fwee fwoo fwip +5 | | argle bargle boo + | '--- outer + '""")); + }); + + test('in the middle', () { + expect( + file + .span(31, 34) + .highlightMultiple('inner', {file.span(12, 70): 'outer'}), + equals(""" + , +2 | / whiz bang boom +3 | | zip zap zop + | | ^^^ inner +4 | | fwee fwoo fwip +5 | | argle bargle boo + | '--- outer + '""")); + }); + + test('on the last line', () { + expect( + file + .span(60, 66) + .highlightMultiple('inner', {file.span(12, 70): 'outer'}), + equals(""" + , +2 | / whiz bang boom +3 | | zip zap zop +4 | | fwee fwoo fwip +5 | | argle bargle boo + | | ^^^^^^ inner + | '--- outer + '""")); + }); + }); + + group('writes headers when highlighting multiple files', () { + test('writes all file URLs', () { + final span2 = SourceFile.fromString(''' +quibble bibble boop +''', url: 'file2.txt').span(8, 14); + + expect( + file.span(31, 34).highlightMultiple('one', {span2: 'two'}), equals(""" + ,--> file1.txt +3 | zip zap zop + | ^^^ one + ' + ,--> file2.txt +1 | quibble bibble boop + | ====== two + '""")); + }); + + test('allows secondary spans to have null URL', () { + final span2 = SourceSpan(SourceLocation(1), SourceLocation(4), 'foo'); + + expect( + file.span(31, 34).highlightMultiple('one', {span2: 'two'}), equals(""" + ,--> file1.txt +3 | zip zap zop + | ^^^ one + ' + , +1 | foo + | === two + '""")); + }); + + test('allows primary span to have null URL', () { + final span1 = SourceSpan(SourceLocation(1), SourceLocation(4), 'foo'); + + expect( + span1.highlightMultiple('one', {file.span(31, 34): 'two'}), equals(""" + , +1 | foo + | ^^^ one + ' + ,--> file1.txt +3 | zip zap zop + | === two + '""")); + }); + }); + + test('highlights multiple null URLs as separate files', () { + final span1 = SourceSpan(SourceLocation(1), SourceLocation(4), 'foo'); + final span2 = SourceSpan(SourceLocation(1), SourceLocation(4), 'bar'); + + expect(span1.highlightMultiple('one', {span2: 'two'}), equals(""" + , +1 | foo + | ^^^ one + ' + , +1 | bar + | === two + '""")); + }); + + group('indents mutli-line labels', () { + test('for the primary label', () { + expect(file.span(17, 21).highlightMultiple('line 1\nline 2\nline 3', {}), + equals(""" + , +2 | whiz bang boom + | ^^^^ line 1 + | line 2 + | line 3 + '""")); + }); + + group('for a secondary label', () { + test('on the same line', () { + expect( + file.span(17, 21).highlightMultiple( + 'primary', {file.span(22, 26): 'line 1\nline 2\nline 3'}), + equals(""" + , +2 | whiz bang boom + | ^^^^ primary + | ==== line 1 + | line 2 + | line 3 + '""")); + }); + + test('on a different line', () { + expect( + file.span(17, 21).highlightMultiple( + 'primary', {file.span(31, 34): 'line 1\nline 2\nline 3'}), + equals(""" + , +2 | whiz bang boom + | ^^^^ primary +3 | zip zap zop + | === line 1 + | line 2 + | line 3 + '""")); + }); + }); + + group('for a multiline span', () { + test('that covers the whole last line', () { + expect( + file.span(12, 70).highlightMultiple('line 1\nline 2\nline 3', {}), + equals(""" + , +2 | / whiz bang boom +3 | | zip zap zop +4 | | fwee fwoo fwip +5 | | argle bargle boo + | '--- line 1 + | line 2 + | line 3 + '""")); + }); + + test('that covers part of the last line', () { + expect( + file.span(12, 66).highlightMultiple('line 1\nline 2\nline 3', {}), + equals(""" + , +2 | / whiz bang boom +3 | | zip zap zop +4 | | fwee fwoo fwip +5 | | argle bargle boo + | '------------^ line 1 + | line 2 + | line 3 + '""")); + }); + }); + + test('with an overlapping span', () { + expect( + file.span(12, 70).highlightMultiple('line 1\nline 2\nline 3', + {file.span(54, 89): 'two', file.span(0, 27): 'three'}), + equals(""" + , +1 | /- foo bar baz +2 | |/ whiz bang boom + | '+--- three +3 | | zip zap zop +4 | | fwee fwoo fwip +5 | /+ argle bargle boo + | |'--- line 1 + | | line 2 + | | line 3 +6 | | gibble bibble bop + | '---- two + '""")); + }); + }); +} diff --git a/pkgs/source_span/test/span_test.dart b/pkgs/source_span/test/span_test.dart new file mode 100644 index 000000000..22c498efb --- /dev/null +++ b/pkgs/source_span/test/span_test.dart @@ -0,0 +1,432 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:source_span/source_span.dart'; +import 'package:source_span/src/colors.dart' as colors; +import 'package:term_glyph/term_glyph.dart' as glyph; +import 'package:test/test.dart'; + +void main() { + late bool oldAscii; + + setUpAll(() { + oldAscii = glyph.ascii; + glyph.ascii = true; + }); + + tearDownAll(() { + glyph.ascii = oldAscii; + }); + + late SourceSpan span; + setUp(() { + span = SourceSpan(SourceLocation(5, sourceUrl: 'foo.dart'), + SourceLocation(12, sourceUrl: 'foo.dart'), 'foo bar'); + }); + + group('errors', () { + group('for new SourceSpan()', () { + test('source URLs must match', () { + final start = SourceLocation(0, sourceUrl: 'foo.dart'); + final end = SourceLocation(1, sourceUrl: 'bar.dart'); + expect(() => SourceSpan(start, end, '_'), throwsArgumentError); + }); + + test('end must come after start', () { + final start = SourceLocation(1); + final end = SourceLocation(0); + expect(() => SourceSpan(start, end, '_'), throwsArgumentError); + }); + + test('text must be the right length', () { + final start = SourceLocation(0); + final end = SourceLocation(1); + expect(() => SourceSpan(start, end, 'abc'), throwsArgumentError); + }); + }); + + group('for new SourceSpanWithContext()', () { + test('context must contain text', () { + final start = SourceLocation(2); + final end = SourceLocation(5); + expect(() => SourceSpanWithContext(start, end, 'abc', '--axc--'), + throwsArgumentError); + }); + + test('text starts at start.column in context', () { + final start = SourceLocation(3); + final end = SourceLocation(5); + expect(() => SourceSpanWithContext(start, end, 'abc', '--abc--'), + throwsArgumentError); + }); + + test('text starts at start.column of line in multi-line context', () { + final start = SourceLocation(4, line: 55, column: 3); + final end = SourceLocation(7, line: 55, column: 6); + expect(() => SourceSpanWithContext(start, end, 'abc', '\n--abc--'), + throwsArgumentError); + expect(() => SourceSpanWithContext(start, end, 'abc', '\n----abc--'), + throwsArgumentError); + expect(() => SourceSpanWithContext(start, end, 'abc', '\n\n--abc--'), + throwsArgumentError); + + // However, these are valid: + SourceSpanWithContext(start, end, 'abc', '\n---abc--'); + SourceSpanWithContext(start, end, 'abc', '\n\n---abc--'); + }); + + test('text can occur multiple times in context', () { + final start1 = SourceLocation(4, line: 55, column: 2); + final end1 = SourceLocation(7, line: 55, column: 5); + final start2 = SourceLocation(4, line: 55, column: 8); + final end2 = SourceLocation(7, line: 55, column: 11); + SourceSpanWithContext(start1, end1, 'abc', '--abc---abc--\n'); + SourceSpanWithContext(start1, end1, 'abc', '--abc--abc--\n'); + SourceSpanWithContext(start2, end2, 'abc', '--abc---abc--\n'); + SourceSpanWithContext(start2, end2, 'abc', '---abc--abc--\n'); + expect( + () => SourceSpanWithContext(start1, end1, 'abc', '---abc--abc--\n'), + throwsArgumentError); + expect( + () => SourceSpanWithContext(start2, end2, 'abc', '--abc--abc--\n'), + throwsArgumentError); + }); + }); + + group('for union()', () { + test('source URLs must match', () { + final other = SourceSpan(SourceLocation(12, sourceUrl: 'bar.dart'), + SourceLocation(13, sourceUrl: 'bar.dart'), '_'); + + expect(() => span.union(other), throwsArgumentError); + }); + + test('spans may not be disjoint', () { + final other = SourceSpan(SourceLocation(13, sourceUrl: 'foo.dart'), + SourceLocation(14, sourceUrl: 'foo.dart'), '_'); + + expect(() => span.union(other), throwsArgumentError); + }); + }); + + test('for compareTo() source URLs must match', () { + final other = SourceSpan(SourceLocation(12, sourceUrl: 'bar.dart'), + SourceLocation(13, sourceUrl: 'bar.dart'), '_'); + + expect(() => span.compareTo(other), throwsArgumentError); + }); + }); + + test('fields work correctly', () { + expect(span.start, equals(SourceLocation(5, sourceUrl: 'foo.dart'))); + expect(span.end, equals(SourceLocation(12, sourceUrl: 'foo.dart'))); + expect(span.sourceUrl, equals(Uri.parse('foo.dart'))); + expect(span.length, equals(7)); + }); + + group('union()', () { + test('works with a preceding adjacent span', () { + final other = SourceSpan(SourceLocation(0, sourceUrl: 'foo.dart'), + SourceLocation(5, sourceUrl: 'foo.dart'), 'hey, '); + + final result = span.union(other); + expect(result.start, equals(other.start)); + expect(result.end, equals(span.end)); + expect(result.text, equals('hey, foo bar')); + }); + + test('works with a preceding overlapping span', () { + final other = SourceSpan(SourceLocation(0, sourceUrl: 'foo.dart'), + SourceLocation(8, sourceUrl: 'foo.dart'), 'hey, foo'); + + final result = span.union(other); + expect(result.start, equals(other.start)); + expect(result.end, equals(span.end)); + expect(result.text, equals('hey, foo bar')); + }); + + test('works with a following adjacent span', () { + final other = SourceSpan(SourceLocation(12, sourceUrl: 'foo.dart'), + SourceLocation(16, sourceUrl: 'foo.dart'), ' baz'); + + final result = span.union(other); + expect(result.start, equals(span.start)); + expect(result.end, equals(other.end)); + expect(result.text, equals('foo bar baz')); + }); + + test('works with a following overlapping span', () { + final other = SourceSpan(SourceLocation(9, sourceUrl: 'foo.dart'), + SourceLocation(16, sourceUrl: 'foo.dart'), 'bar baz'); + + final result = span.union(other); + expect(result.start, equals(span.start)); + expect(result.end, equals(other.end)); + expect(result.text, equals('foo bar baz')); + }); + + test('works with an internal overlapping span', () { + final other = SourceSpan(SourceLocation(7, sourceUrl: 'foo.dart'), + SourceLocation(10, sourceUrl: 'foo.dart'), 'o b'); + + expect(span.union(other), equals(span)); + }); + + test('works with an external overlapping span', () { + final other = SourceSpan(SourceLocation(0, sourceUrl: 'foo.dart'), + SourceLocation(16, sourceUrl: 'foo.dart'), 'hey, foo bar baz'); + + expect(span.union(other), equals(other)); + }); + }); + + group('subspan()', () { + group('errors', () { + test('start must be greater than zero', () { + expect(() => span.subspan(-1), throwsRangeError); + }); + + test('start must be less than or equal to length', () { + expect(() => span.subspan(span.length + 1), throwsRangeError); + }); + + test('end must be greater than start', () { + expect(() => span.subspan(2, 1), throwsRangeError); + }); + + test('end must be less than or equal to length', () { + expect(() => span.subspan(0, span.length + 1), throwsRangeError); + }); + }); + + test('preserves the source URL', () { + final result = span.subspan(1, 2); + expect(result.start.sourceUrl, equals(span.sourceUrl)); + expect(result.end.sourceUrl, equals(span.sourceUrl)); + }); + + test('preserves the context', () { + final start = SourceLocation(2); + final end = SourceLocation(5); + final span = SourceSpanWithContext(start, end, 'abc', '--abc--'); + expect(span.subspan(1, 2).context, equals('--abc--')); + }); + + group('returns the original span', () { + test('with an implicit end', () => expect(span.subspan(0), equals(span))); + + test('with an explicit end', + () => expect(span.subspan(0, span.length), equals(span))); + }); + + group('within a single line', () { + test('returns a strict substring of the original span', () { + final result = span.subspan(1, 5); + expect(result.text, equals('oo b')); + expect(result.start.offset, equals(6)); + expect(result.start.line, equals(0)); + expect(result.start.column, equals(6)); + expect(result.end.offset, equals(10)); + expect(result.end.line, equals(0)); + expect(result.end.column, equals(10)); + }); + + test('an implicit end goes to the end of the original span', () { + final result = span.subspan(1); + expect(result.text, equals('oo bar')); + expect(result.start.offset, equals(6)); + expect(result.start.line, equals(0)); + expect(result.start.column, equals(6)); + expect(result.end.offset, equals(12)); + expect(result.end.line, equals(0)); + expect(result.end.column, equals(12)); + }); + + test('can return an empty span', () { + final result = span.subspan(3, 3); + expect(result.text, isEmpty); + expect(result.start.offset, equals(8)); + expect(result.start.line, equals(0)); + expect(result.start.column, equals(8)); + expect(result.end, equals(result.start)); + }); + }); + + group('across multiple lines', () { + setUp(() { + span = SourceSpan( + SourceLocation(5, line: 2, column: 0), + SourceLocation(16, line: 4, column: 3), + 'foo\n' + 'bar\n' + 'baz'); + }); + + test('with start and end in the middle of a line', () { + final result = span.subspan(2, 5); + expect(result.text, equals('o\nb')); + expect(result.start.offset, equals(7)); + expect(result.start.line, equals(2)); + expect(result.start.column, equals(2)); + expect(result.end.offset, equals(10)); + expect(result.end.line, equals(3)); + expect(result.end.column, equals(1)); + }); + + test('with start at the end of a line', () { + final result = span.subspan(3, 5); + expect(result.text, equals('\nb')); + expect(result.start.offset, equals(8)); + expect(result.start.line, equals(2)); + expect(result.start.column, equals(3)); + }); + + test('with start at the beginning of a line', () { + final result = span.subspan(4, 5); + expect(result.text, equals('b')); + expect(result.start.offset, equals(9)); + expect(result.start.line, equals(3)); + expect(result.start.column, equals(0)); + }); + + test('with end at the end of a line', () { + final result = span.subspan(2, 3); + expect(result.text, equals('o')); + expect(result.end.offset, equals(8)); + expect(result.end.line, equals(2)); + expect(result.end.column, equals(3)); + }); + + test('with end at the beginning of a line', () { + final result = span.subspan(2, 4); + expect(result.text, equals('o\n')); + expect(result.end.offset, equals(9)); + expect(result.end.line, equals(3)); + expect(result.end.column, equals(0)); + }); + }); + }); + + group('message()', () { + test('prints the text being described', () { + expect(span.message('oh no'), equals(""" +line 1, column 6 of foo.dart: oh no + , +1 | foo bar + | ^^^^^^^ + '""")); + }); + + test('gracefully handles a missing source URL', () { + final span = SourceSpan(SourceLocation(5), SourceLocation(12), 'foo bar'); + + expect(span.message('oh no'), equalsIgnoringWhitespace(""" +line 1, column 6: oh no + , +1 | foo bar + | ^^^^^^^ + '""")); + }); + + test('gracefully handles empty text', () { + final span = SourceSpan(SourceLocation(5), SourceLocation(5), ''); + + expect(span.message('oh no'), equals('line 1, column 6: oh no')); + }); + + test("doesn't colorize if color is false", () { + expect(span.message('oh no', color: false), equals(""" +line 1, column 6 of foo.dart: oh no + , +1 | foo bar + | ^^^^^^^ + '""")); + }); + + test('colorizes if color is true', () { + expect(span.message('oh no', color: true), equals(""" +line 1, column 6 of foo.dart: oh no +${colors.blue} ,${colors.none} +${colors.blue}1 |${colors.none} ${colors.red}foo bar${colors.none} +${colors.blue} |${colors.none} ${colors.red}^^^^^^^${colors.none} +${colors.blue} '${colors.none}""")); + }); + + test("uses the given color if it's passed", () { + expect(span.message('oh no', color: colors.yellow), equals(""" +line 1, column 6 of foo.dart: oh no +${colors.blue} ,${colors.none} +${colors.blue}1 |${colors.none} ${colors.yellow}foo bar${colors.none} +${colors.blue} |${colors.none} ${colors.yellow}^^^^^^^${colors.none} +${colors.blue} '${colors.none}""")); + }); + + test('with context, underlines the right column', () { + final spanWithContext = SourceSpanWithContext( + SourceLocation(5, sourceUrl: 'foo.dart'), + SourceLocation(12, sourceUrl: 'foo.dart'), + 'foo bar', + '-----foo bar-----'); + + expect(spanWithContext.message('oh no', color: colors.yellow), equals(""" +line 1, column 6 of foo.dart: oh no +${colors.blue} ,${colors.none} +${colors.blue}1 |${colors.none} -----${colors.yellow}foo bar${colors.none}----- +${colors.blue} |${colors.none} ${colors.yellow} ^^^^^^^${colors.none} +${colors.blue} '${colors.none}""")); + }); + }); + + group('compareTo()', () { + test('sorts by start location first', () { + final other = SourceSpan(SourceLocation(6, sourceUrl: 'foo.dart'), + SourceLocation(14, sourceUrl: 'foo.dart'), 'oo bar b'); + + expect(span.compareTo(other), lessThan(0)); + expect(other.compareTo(span), greaterThan(0)); + }); + + test('sorts by length second', () { + final other = SourceSpan(SourceLocation(5, sourceUrl: 'foo.dart'), + SourceLocation(14, sourceUrl: 'foo.dart'), 'foo bar b'); + + expect(span.compareTo(other), lessThan(0)); + expect(other.compareTo(span), greaterThan(0)); + }); + + test('considers equal spans equal', () { + expect(span.compareTo(span), equals(0)); + }); + }); + + group('equality', () { + test('two spans with the same locations are equal', () { + final other = SourceSpan(SourceLocation(5, sourceUrl: 'foo.dart'), + SourceLocation(12, sourceUrl: 'foo.dart'), 'foo bar'); + + expect(span, equals(other)); + }); + + test("a different start isn't equal", () { + final other = SourceSpan(SourceLocation(0, sourceUrl: 'foo.dart'), + SourceLocation(12, sourceUrl: 'foo.dart'), 'hey, foo bar'); + + expect(span, isNot(equals(other))); + }); + + test("a different end isn't equal", () { + final other = SourceSpan(SourceLocation(5, sourceUrl: 'foo.dart'), + SourceLocation(16, sourceUrl: 'foo.dart'), 'foo bar baz'); + + expect(span, isNot(equals(other))); + }); + + test("a different source URL isn't equal", () { + final other = SourceSpan(SourceLocation(5, sourceUrl: 'bar.dart'), + SourceLocation(12, sourceUrl: 'bar.dart'), 'foo bar'); + + expect(span, isNot(equals(other))); + }); + }); +} diff --git a/pkgs/source_span/test/utils_test.dart b/pkgs/source_span/test/utils_test.dart new file mode 100644 index 000000000..91397c01b --- /dev/null +++ b/pkgs/source_span/test/utils_test.dart @@ -0,0 +1,58 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:source_span/src/utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('find line start', () { + test('skip entries in wrong column', () { + const context = '0_bb\n1_bbb\n2b____\n3bbb\n'; + final index = findLineStart(context, 'b', 1)!; + expect(index, 11); + expect(context.substring(index - 1, index + 3), '\n2b_'); + }); + + test('end of line column for empty text', () { + const context = '0123\n56789\nabcdefgh\n'; + final index = findLineStart(context, '', 5)!; + expect(index, 5); + expect(context[index], '5'); + }); + + test('column at the end of the file for empty text', () { + var context = '0\n2\n45\n'; + var index = findLineStart(context, '', 2)!; + expect(index, 4); + expect(context[index], '4'); + + context = '0\n2\n45'; + index = findLineStart(context, '', 2)!; + expect(index, 4); + }); + + test('empty text in empty context', () { + final index = findLineStart('', '', 0); + expect(index, 0); + }); + + test('found on the first line', () { + const context = '0\n2\n45\n'; + final index = findLineStart(context, '0', 0); + expect(index, 0); + }); + + test('finds text that starts with a newline', () { + const context = '0\n2\n45\n'; + final index = findLineStart(context, '\n2', 1); + expect(index, 0); + }); + + test('not found', () { + const context = '0\n2\n45\n'; + final index = findLineStart(context, '0', 1); + expect(index, isNull); + }); + }); +} diff --git a/pkgs/sse/.gitignore b/pkgs/sse/.gitignore new file mode 100644 index 000000000..146778230 --- /dev/null +++ b/pkgs/sse/.gitignore @@ -0,0 +1,3 @@ +.dart_tool +pubspec.lock +test/web/index.dart.js.deps diff --git a/pkgs/sse/AUTHORS b/pkgs/sse/AUTHORS new file mode 100644 index 000000000..7c12ae681 --- /dev/null +++ b/pkgs/sse/AUTHORS @@ -0,0 +1,6 @@ +# Below is a list of people and organizations that have contributed +# to the Dart project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. diff --git a/pkgs/sse/CHANGELOG.md b/pkgs/sse/CHANGELOG.md new file mode 100644 index 000000000..0387ba98f --- /dev/null +++ b/pkgs/sse/CHANGELOG.md @@ -0,0 +1,178 @@ +## 4.1.7 + +- Move to `dart-lang/tools` monorepo. + +## 4.1.6 + +- Require package `web: '>=0.5.0 <2.0.0'`. + +## 4.1.5 + +- Drop unneeded dependency on `package:js`. +- Update the minimum Dart SDK version to `3.3.0`. +- Support the latest `package:web`. + +## 4.1.4 + +- Fix incorrect cast causing failure with `dart2wasm`. + +## 4.1.3 + +- Update the minimum Dart SDK version to `3.2.0`. + +## 4.1.2 + +- Send `fetch` requests instead of `XHR` requests. +- Add an optional `debugKey` parameter to `SseClient` to include in logging. +- Add a dependency on `package:js`. +- Update the minimum Dart SDK version to `2.16.0`. + +## 4.1.1 + +- Apply `keepAlive` logic to `SocketException`s. +- Switch from using `package:pedantic` to `package:lints` +- Rev the minimum required SDK to 2.15. +- Populate the pubspec `repository` field. + +## 4.1.0 + +- Limit the number of concurrent requests to prevent Chrome from automatically + dropping them on the floor. + +## 4.0.0 + +- Support null safety. + +## 3.8.3 + +- Require the latest shelf and remove dead code. + +## 3.8.2 + +- Complete `onConnected` with an error if the `SseClient` receives an error + before the connection is successfully opened. + +## 3.8.1 + +- Fix an issue where closing the `SseConnection` stream would result in an + error. + +## 3.8.0 + +- Add `onConnected` to replace `onOpen`. +- Fix an issue where failed requests would not add a `done` event to the + connection `sink`. + +## 3.7.0 + +- Deprecate the client's `onOpen` getter. Messages will now be buffered until a + connection is established. + +## 3.6.1 + +- Drop dependency on `package:uuid`. + +## 3.6.0 + +- Improve performance by buffering out of order messages in the server instead + of the client. + +\*\* Note \*\* This is not modelled as a breaking change as the server can +handle messages from older clients. However, clients should be using the latest +server if they require order guarantees. + +## 3.5.0 + +- Add new `shutdown` methods on `SseHandler` and `SseConnection` to allow + closing connections immediately, ignoring any keep-alive periods. + +## 3.4.0 + +- Remove `onClose` from `SseConnection` and ensure the corresponding + `sink.close` correctly fires. + +## 3.3.0 + +- Add an `onClose` event to the `SseConnection`. This allows consumers to listen + to this event in lue of `sseConnection.sink.done` as that is not guaranteed to + fire. + +## 3.2.2 + +- Fix an issue where `keepAlive` may cause state errors when attempting to send + messages on a closed stream. + +## 3.2.1 + +- Fix an issue where `keepAlive` would only allow a single reconnection. + +## 3.2.0 + +- Re-expose `isInKeepAlivePeriod` flag on `SseConnection`. This flag will be + `true` when a connection has been dropped and is in the keep-alive period + waiting for a client to reconnect. + +## 3.1.2 + +- Fix an issue where the `SseClient` would not send a `done` event when there + was an error with the SSE connection. + +## 3.1.1 + +- Make `isInKeepAlive` on `SseConnection` private. + +**Note that this is a breaking change but in actuality no one should be +depending on this API.** + +## 3.1.0 + +- Add optional `keepAlive` parameter to the `SseHandler`. If `keepAlive` is + supplied, the connection will remain active for this period after a disconnect + and can be reconnected transparently. If there is no reconnect within that + period, the connection will be closed normally. + +## 3.0.0 + +- Add retry logic. + +**Possible Breaking Change Error messages may now be delayed up to 5 seconds in +the client.** + +## 2.1.2 + +- Remove `package:http` dependency. + +## 2.1.1 + +- Use proper headers delimiter. + +## 2.1.0 + +- Support Firefox. + +## 2.0.3 + +- Fix an issue where messages could come out of order. + +## 2.0.2 + +- Support the latest `package:stream_channel`. +- Require Dart SDK `>=2.1.0 <3.0.0`. + +## 2.0.1 + +- Update to `package:uuid` version 2.0. + +## 2.0.0 + +- No longer expose `close` and `onClose` on an `SseConnection`. This is simply + handled by the underlying `stream` / `sink`. +- Fix a bug where resources of the `SseConnection` were not properly closed. + +## 1.0.0 + +- Internal cleanup. + +## 0.0.1 + +- Initial commit. diff --git a/pkgs/sse/LICENSE b/pkgs/sse/LICENSE new file mode 100644 index 000000000..a0d5f5431 --- /dev/null +++ b/pkgs/sse/LICENSE @@ -0,0 +1,27 @@ +Copyright 2019, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/sse/README.md b/pkgs/sse/README.md new file mode 100644 index 000000000..ef514155e --- /dev/null +++ b/pkgs/sse/README.md @@ -0,0 +1,14 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/sse.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/sse.yaml) +[![pub package](https://img.shields.io/pub/v/sse.svg)](https://pub.dev/packages/sse) +[![package publisher](https://img.shields.io/pub/publisher/sse.svg)](https://pub.dev/packages/sse/publisher) + +This package provides support for bi-directional communication through Server +Sent Events and corresponding POST requests. + +This package is not intended to be a general purpose SSE package, but instead is +a bidirectional protocol for use when Websockets are unavailable. That is, both +the client and the server expose a `sink` and `stream` on which to send and +receive messages respectively. + +Both the server and client have implicit assumptions on each other and therefore +a client from this package must be paired with a server from this package. diff --git a/pkgs/sse/analysis_options.yaml b/pkgs/sse/analysis_options.yaml new file mode 100644 index 000000000..6729bd923 --- /dev/null +++ b/pkgs/sse/analysis_options.yaml @@ -0,0 +1,13 @@ +# https://dart.dev/guides/language/analysis-options +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true + +linter: + rules: + - avoid_unused_constructor_parameters + - cancel_subscriptions + - literal_only_boolean_expressions + - no_adjacent_strings_in_list diff --git a/pkgs/sse/example/index.dart b/pkgs/sse/example/index.dart new file mode 100644 index 000000000..0ed75966c --- /dev/null +++ b/pkgs/sse/example/index.dart @@ -0,0 +1,15 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:sse/client/sse_client.dart'; + +/// A basic example which should be used in a browser that supports SSE. +void main() { + var channel = SseClient('/sseHandler'); + + channel.stream.listen((s) { + // Listen for messages and send them back. + channel.sink.add(s); + }); +} diff --git a/pkgs/sse/example/server.dart b/pkgs/sse/example/server.dart new file mode 100644 index 000000000..b6ee75067 --- /dev/null +++ b/pkgs/sse/example/server.dart @@ -0,0 +1,21 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:shelf/shelf_io.dart' as io; +import 'package:sse/server/sse_handler.dart'; + +/// A basic server which sets up an SSE handler. +/// +/// When a client connects it will send a simple message and print the +/// response. +void main() async { + var handler = SseHandler(Uri.parse('/sseHandler')); + await io.serve(handler.handler, 'localhost', 0); + var connections = handler.connections; + while (await connections.hasNext) { + var connection = await connections.next; + connection.sink.add('foo'); + connection.stream.listen(print); + } +} diff --git a/pkgs/sse/lib/client/sse_client.dart b/pkgs/sse/lib/client/sse_client.dart new file mode 100644 index 000000000..4d3df490c --- /dev/null +++ b/pkgs/sse/lib/client/sse_client.dart @@ -0,0 +1,166 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; + +import 'package:logging/logging.dart'; +import 'package:pool/pool.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:web/web.dart'; + +import '../src/util/uuid.dart'; + +/// Limit for the number of concurrent outgoing requests. +/// +/// Chrome drops outgoing requests on the floor after some threshold. To prevent +/// these errors we buffer outgoing requests with a pool. +/// +/// Note Chrome's limit is 6000. So this gives us plenty of headroom. +final _requestPool = Pool(1000); + +/// A client for bi-directional sse communication. +/// +/// The client can send any JSON-encodable messages to the server by adding +/// them to the [sink] and listen to messages from the server on the [stream]. +class SseClient extends StreamChannelMixin { + final String _clientId; + + final _incomingController = StreamController(); + + final _outgoingController = StreamController(); + + final _logger = Logger('SseClient'); + + final _onConnected = Completer(); + + int _lastMessageId = -1; + + late EventSource _eventSource; + + late String _serverUrl; + + Timer? _errorTimer; + + /// [serverUrl] is the URL under which the server is listening for + /// incoming bi-directional SSE connections. [debugKey] is an optional key + /// that can be used to identify the SSE connection. + SseClient(String serverUrl, {String? debugKey}) + : _clientId = debugKey == null + ? generateUuidV4() + : '$debugKey-${generateUuidV4()}' { + _serverUrl = '$serverUrl?sseClientId=$_clientId'; + _eventSource = + EventSource(_serverUrl, EventSourceInit(withCredentials: true)); + _eventSource.onOpen.first.whenComplete(() { + _onConnected.complete(); + _outgoingController.stream + .listen(_onOutgoingMessage, onDone: _onOutgoingDone); + }); + _eventSource.addEventListener('message', _onIncomingMessage.toJS); + _eventSource.addEventListener('control', _onIncomingControlMessage.toJS); + + _eventSource.onOpen.listen((_) { + _errorTimer?.cancel(); + }); + _eventSource.onError.listen((error) { + if (!(_errorTimer?.isActive ?? false)) { + // By default the SSE client uses keep-alive. + // Allow for a retry to connect before giving up. + _errorTimer = Timer(const Duration(seconds: 5), () { + _closeWithError(error); + }); + } + }); + } + + @Deprecated('Use onConnected instead.') + Stream get onOpen => _eventSource.onOpen; + + Future get onConnected => _onConnected.future; + + /// Add messages to this [StreamSink] to send them to the server. + /// + /// The message added to the sink has to be JSON encodable. Messages that fail + /// to encode will be logged through a [Logger]. + @override + StreamSink get sink => _outgoingController.sink; + + /// [Stream] of messages sent from the server to this client. + /// + /// A message is a decoded JSON object. + @override + Stream get stream => _incomingController.stream; + + void close() { + _eventSource.close(); + // If the initial connection was never established. Add a listener so close + // adds a done event to [sink]. + if (!_onConnected.isCompleted) _outgoingController.stream.drain(); + _incomingController.close(); + _outgoingController.close(); + } + + void _closeWithError(Object error) { + _incomingController.addError(error); + close(); + if (!_onConnected.isCompleted) { + // This call must happen after the call to close() which checks + // whether the completer was completed earlier. + _onConnected.completeError(error); + } + } + + void _onIncomingControlMessage(Event message) { + var data = (message as MessageEvent).data; + if (data.dartify() == 'close') { + close(); + } else { + throw UnsupportedError('[$_clientId] Illegal Control Message "$data"'); + } + } + + void _onIncomingMessage(Event message) { + var decoded = + jsonDecode(((message as MessageEvent).data as JSString).toDart); + _incomingController.add(decoded as String); + } + + void _onOutgoingDone() { + close(); + } + + void _onOutgoingMessage(String? message) async { + String? encodedMessage; + await _requestPool.withResource(() async { + try { + encodedMessage = jsonEncode(message); + // ignore: avoid_catching_errors + } on JsonUnsupportedObjectError catch (e) { + _logger.warning('[$_clientId] Unable to encode outgoing message: $e'); + // ignore: avoid_catching_errors + } on ArgumentError catch (e) { + _logger.warning('[$_clientId] Invalid argument: $e'); + } + try { + final url = '$_serverUrl&messageId=${++_lastMessageId}'; + await _fetch( + url, + RequestInit( + method: 'POST', + body: encodedMessage?.toJS, + credentials: 'include')); + } catch (error) { + final augmentedError = + '[$_clientId] SSE client failed to send $message:\n $error'; + _logger.severe(augmentedError); + _closeWithError(augmentedError); + } + }); + } +} + +Future _fetch(String resourceUrl, RequestInit options) => + window.fetch(resourceUrl.toJS, options).toDart; diff --git a/pkgs/sse/lib/server/sse_handler.dart b/pkgs/sse/lib/server/sse_handler.dart new file mode 100644 index 000000000..bfed9350b --- /dev/null +++ b/pkgs/sse/lib/server/sse_handler.dart @@ -0,0 +1,5 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'package:sse/src/server/sse_handler.dart' show SseConnection, SseHandler; diff --git a/pkgs/sse/lib/src/server/sse_handler.dart b/pkgs/sse/lib/src/server/sse_handler.dart new file mode 100644 index 000000000..376fe27bf --- /dev/null +++ b/pkgs/sse/lib/src/server/sse_handler.dart @@ -0,0 +1,299 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'package:stream_channel/stream_channel.dart'; + +// RFC 2616 requires carriage return delimiters. +String _sseHeaders(String? origin) => 'HTTP/1.1 200 OK\r\n' + 'Content-Type: text/event-stream\r\n' + 'Cache-Control: no-cache\r\n' + 'Connection: keep-alive\r\n' + 'Access-Control-Allow-Credentials: true\r\n' + "${origin != null ? 'Access-Control-Allow-Origin: $origin\r\n' : ''}" + '\r\n\r\n'; + +class _SseMessage { + final int id; + final String message; + _SseMessage(this.id, this.message); +} + +/// A bi-directional SSE connection between server and browser. +class SseConnection extends StreamChannelMixin { + /// Incoming messages from the Browser client. + final _incomingController = StreamController(); + + /// Outgoing messages to the Browser client. + final _outgoingController = StreamController(); + + Sink _sink; + + /// How long to wait after a connection drops before considering it closed. + final Duration? _keepAlive; + + /// A timer counting down the KeepAlive period (null if hasn't disconnected). + Timer? _keepAliveTimer; + + /// Whether this connection is currently in the KeepAlive timeout period. + bool get isInKeepAlivePeriod => _keepAliveTimer?.isActive ?? false; + + /// The id of the last processed incoming message. + int _lastProcessedId = -1; + + /// Incoming messages that have yet to be processed. + final _pendingMessages = + HeapPriorityQueue<_SseMessage>((a, b) => a.id.compareTo(b.id)); + + final _closedCompleter = Completer(); + + /// Wraps the `_outgoingController.stream` to buffer events to enable keep + /// alive. + late StreamQueue _outgoingStreamQueue; + + /// Creates an [SseConnection] for the supplied [_sink]. + /// + /// If [keepAlive] is supplied, the connection will remain active for this + /// period after a disconnect and can be reconnected transparently. If there + /// is no reconnect within that period, the connection will be closed + /// normally. + /// + /// If [keepAlive] is not supplied, the connection will be closed immediately + /// after a disconnect. + SseConnection(this._sink, {Duration? keepAlive}) : _keepAlive = keepAlive { + _outgoingStreamQueue = StreamQueue(_outgoingController.stream); + unawaited(_setUpListener()); + _outgoingController.onCancel = _close; + _incomingController.onCancel = _close; + } + + Future _setUpListener() async { + while ( + !_outgoingController.isClosed && await _outgoingStreamQueue.hasNext) { + // If we're in a KeepAlive timeout, there's nowhere to send messages so + // wait a short period and check again. + if (isInKeepAlivePeriod) { + await Future.delayed(const Duration(milliseconds: 200)); + continue; + } + + // Peek the data so we don't remove it from the stream if we're unable to + // send it. + final data = await _outgoingStreamQueue.peek; + + // Ignore outgoing messages since the connection may have closed while + // waiting for the keep alive. + if (_closedCompleter.isCompleted) break; + + try { + // JSON encode the message to escape new lines. + _sink.add('data: ${json.encode(data)}\n'); + _sink.add('\n'); + await _outgoingStreamQueue.next; // Consume from stream if no errors. + } catch (e) { + if ((e is StateError || e is SocketException) && + (_keepAlive != null && !_closedCompleter.isCompleted)) { + // If we got here then the sink may have closed but the stream.onDone + // hasn't fired yet, so pause the subscription and skip calling + // `next` so the message remains in the queue to try again. + _handleDisconnect(); + } else { + rethrow; + } + } + } + } + + /// The message added to the sink has to be JSON encodable. + @override + StreamSink get sink => _outgoingController.sink; + + // Add messages to this [StreamSink] to send them to the server. + /// [Stream] of messages sent from the server to this client. + /// + /// A message is a decoded JSON object. + @override + Stream get stream => _incomingController.stream; + + /// Adds an incoming [message] to the [stream]. + /// + /// This will buffer messages to guarantee order. + void _addIncomingMessage(int id, String message) { + _pendingMessages.add(_SseMessage(id, message)); + while (_pendingMessages.isNotEmpty) { + var pendingMessage = _pendingMessages.first; + // Only process the next incremental message. + if (pendingMessage.id - _lastProcessedId <= 1) { + _incomingController.sink.add(pendingMessage.message); + _lastProcessedId = pendingMessage.id; + _pendingMessages.removeFirst(); + } else { + // A message came out of order. Wait until we receive the previous + // messages to process. + break; + } + } + } + + void _acceptReconnection(Sink sink) { + _keepAliveTimer?.cancel(); + _sink = sink; + } + + void _handleDisconnect() { + if (_keepAlive == null) { + // Close immediately if we're not keeping alive. + _close(); + } else if (!isInKeepAlivePeriod && !_closedCompleter.isCompleted) { + // Otherwise if we didn't already have an active timer and we've not + // already been completely closed, set a timer to close after the timeout + // period. + // If the connection comes back, this will be cancelled and all messages + // left in the queue tried again. + _keepAliveTimer = Timer(_keepAlive, _close); + } + } + + void _close() { + if (!_closedCompleter.isCompleted) { + _closedCompleter.complete(); + // Cancel any existing timer in case we were told to explicitly shut down + // to avoid keeping the process alive. + _keepAliveTimer?.cancel(); + _sink.close(); + if (!_outgoingController.isClosed) { + _outgoingStreamQueue.cancel(immediate: true); + _outgoingController.close(); + } + if (!_incomingController.isClosed) _incomingController.close(); + } + } + + /// Immediately close the connection, ignoring any keepAlive period. + void shutdown() { + _close(); + } +} + +/// [SseHandler] handles requests on a user defined path to create +/// two-way communications of JSON encodable data between server and clients. +/// +/// A server sends messages to a client through an SSE channel, while +/// a client sends message to a server through HTTP POST requests. +class SseHandler { + final _logger = Logger('SseHandler'); + final Uri _uri; + final Duration? _keepAlive; + final _connections = {}; + final _connectionController = StreamController(); + + StreamQueue? _connectionsStream; + + /// [_uri] is the URL under which the server is listening for + /// incoming bi-directional SSE connections. + /// + /// If [keepAlive] is supplied, connections will remain active for this + /// period after a disconnect and can be reconnected transparently. If there + /// is no reconnect within that period, the connection will be closed + /// normally. + /// + /// If [keepAlive] is not supplied, connections will be closed immediately + /// after a disconnect. + SseHandler(this._uri, {Duration? keepAlive}) : _keepAlive = keepAlive; + + StreamQueue get connections => + _connectionsStream ??= StreamQueue(_connectionController.stream); + + shelf.Handler get handler => _handle; + + int get numberOfClients => _connections.length; + + shelf.Response _createSseConnection(shelf.Request req, String path) { + req.hijack((channel) async { + var sink = utf8.encoder.startChunkedConversion(channel.sink); + sink.add(_sseHeaders(req.headers['origin'])); + var clientId = req.url.queryParameters['sseClientId']; + + // Check if we already have a connection for this ID that is in the + // process of timing out + // (in which case we can reconnect it transparently). + if (_connections[clientId] != null && + _connections[clientId]!.isInKeepAlivePeriod) { + _connections[clientId]!._acceptReconnection(sink); + } else { + var connection = SseConnection(sink, keepAlive: _keepAlive); + _connections[clientId] = connection; + unawaited(connection._closedCompleter.future.then((_) { + _connections.remove(clientId); + })); + _connectionController.add(connection); + } + // Remove connection when it is remotely closed or the stream is + // cancelled. + channel.stream.listen((_) { + // SSE is unidirectional. Responses are handled through POST requests. + }, onDone: () { + _connections[clientId]?._handleDisconnect(); + }); + }); + } + + String _getOriginalPath(shelf.Request req) => req.requestedUri.path; + + Future _handle(shelf.Request req) async { + var path = _getOriginalPath(req); + if (_uri.path != path) { + return shelf.Response.notFound(''); + } + + if (req.headers['accept'] == 'text/event-stream' && req.method == 'GET') { + return _createSseConnection(req, path); + } + + if (req.headers['accept'] != 'text/event-stream' && req.method == 'POST') { + return _handleIncomingMessage(req, path); + } + + return shelf.Response.notFound(''); + } + + Future _handleIncomingMessage( + shelf.Request req, String path) async { + String? clientId; + try { + clientId = req.url.queryParameters['sseClientId']; + var messageId = int.parse(req.url.queryParameters['messageId'] ?? '0'); + var message = await req.readAsString(); + var jsonObject = json.decode(message) as String; + _connections[clientId]?._addIncomingMessage(messageId, jsonObject); + } catch (e, st) { + _logger.fine('[$clientId] Failed to handle incoming message. $e $st'); + } + return shelf.Response.ok('', headers: { + 'access-control-allow-credentials': 'true', + 'access-control-allow-origin': _originFor(req), + }); + } + + String _originFor(shelf.Request req) => + // Firefox does not set header "origin". + // https://bugzilla.mozilla.org/show_bug.cgi?id=1508661 + req.headers['origin'] ?? req.headers['host']!; + + /// Immediately close all connections, ignoring any keepAlive periods. + void shutdown() { + for (final connection in _connections.values) { + connection.shutdown(); + } + } +} + +void closeSink(SseConnection connection) => connection._sink.close(); diff --git a/pkgs/sse/lib/src/util/uuid.dart b/pkgs/sse/lib/src/util/uuid.dart new file mode 100644 index 000000000..a1aa39846 --- /dev/null +++ b/pkgs/sse/lib/src/util/uuid.dart @@ -0,0 +1,32 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:math' show Random; + +/// Returns a unique ID in the format: +/// +/// f47ac10b-58cc-4372-a567-0e02b2c3d479 +/// +/// The generated uuids are 128 bit numbers encoded in a specific string format. +/// For more information, see +/// [en.wikipedia.org/wiki/Universally_unique_identifier](http://en.wikipedia.org/wiki/Universally_unique_identifier). +String generateUuidV4() { + final random = Random(); + + int generateBits(int bitCount) => random.nextInt(1 << bitCount); + + String printDigits(int value, int count) => + value.toRadixString(16).padLeft(count, '0'); + String bitsDigits(int bitCount, int digitCount) => + printDigits(generateBits(bitCount), digitCount); + + // Generate xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx / 8-4-4-4-12. + var special = 8 + random.nextInt(4); + + return '${bitsDigits(16, 4)}${bitsDigits(16, 4)}-' + '${bitsDigits(16, 4)}-' + '4${bitsDigits(12, 3)}-' + '${printDigits(special, 1)}${bitsDigits(12, 3)}-' + '${bitsDigits(16, 4)}${bitsDigits(16, 4)}${bitsDigits(16, 4)}'; +} diff --git a/pkgs/sse/pubspec.yaml b/pkgs/sse/pubspec.yaml new file mode 100644 index 000000000..bd70f7452 --- /dev/null +++ b/pkgs/sse/pubspec.yaml @@ -0,0 +1,25 @@ +name: sse +version: 4.1.7 +description: >- + Provides client and server functionality for setting up bi-directional + communication through Server Sent Events (SSE) and corresponding POST + requests. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/sse + +environment: + sdk: ^3.3.0 + +dependencies: + async: ^2.0.8 + collection: ^1.0.0 + logging: ^1.0.0 + pool: ^1.5.0 + shelf: ^1.1.0 + stream_channel: ^2.0.0 + web: '>=0.5.0 <2.0.0' + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + shelf_static: ^1.0.0 + test: ^1.16.6 + webdriver: ^3.0.0 diff --git a/pkgs/sse/test/sse_test.dart b/pkgs/sse/test/sse_test.dart new file mode 100644 index 000000000..0455baa0f --- /dev/null +++ b/pkgs/sse/test/sse_test.dart @@ -0,0 +1,270 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'package:shelf/shelf_io.dart' as io; +import 'package:shelf_static/shelf_static.dart'; +import 'package:sse/server/sse_handler.dart'; +import 'package:sse/src/server/sse_handler.dart' show closeSink; +import 'package:test/test.dart'; +import 'package:webdriver/async_io.dart'; + +void main() { + late HttpServer server; + late WebDriver webdriver; + late SseHandler handler; + late Process chromeDriver; + + setUpAll(() async { + try { + chromeDriver = await Process.start( + 'chromedriver', ['--port=4444', '--url-base=wd/hub']); + } catch (e) { + throw StateError( + 'Could not start ChromeDriver. Is it installed?\nError: $e'); + } + }); + + tearDownAll(() { + chromeDriver.kill(); + }); + + group('SSE', () { + setUp(() async { + handler = SseHandler(Uri.parse('/test')); + + var cascade = shelf.Cascade() + .add(handler.handler) + .add(_faviconHandler) + .add(createStaticHandler('test/web', + listDirectories: true, defaultDocument: 'index.html')); + + server = await io.serve(cascade.handler, 'localhost', 0); + var capabilities = Capabilities.chrome + ..addAll({ + Capabilities.chromeOptions: { + 'args': ['--headless'] + } + }); + webdriver = await createDriver(desired: capabilities); + }); + + tearDown(() async { + await webdriver.quit(); + await server.close(); + }); + + test('can round trip messages', () async { + await webdriver.get('http://localhost:${server.port}'); + var connection = await handler.connections.next; + connection.sink.add('blah'); + expect(await connection.stream.first, 'blah'); + }); + + test('can send a significant number of requests', () async { + await webdriver.get('http://localhost:${server.port}'); + var connection = await handler.connections.next; + var limit = 7000; + for (var i = 0; i < limit; i++) { + connection.sink.add('$i'); + } + await connection.stream.take(limit).drain(); + }); + + test('messages arrive in-order', () async { + expect(handler.numberOfClients, 0); + await webdriver.get('http://localhost:${server.port}'); + var connection = await handler.connections.next; + expect(handler.numberOfClients, 1); + + var expected = []; + var count = 100; + for (var i = 0; i < count; i++) { + expected.add(i.toString()); + } + connection.sink.add('send $count'); + + expect(await connection.stream.take(count).toList(), equals(expected)); + }); + + test('multiple clients can connect', () async { + var connections = handler.connections; + await webdriver.get('http://localhost:${server.port}'); + await connections.next; + await webdriver.get('http://localhost:${server.port}'); + await connections.next; + }); + + test('routes data correctly', () async { + var connections = handler.connections; + await webdriver.get('http://localhost:${server.port}'); + var connectionA = await connections.next; + connectionA.sink.add('foo'); + expect(await connectionA.stream.first, 'foo'); + + await webdriver.get('http://localhost:${server.port}'); + var connectionB = await connections.next; + connectionB.sink.add('bar'); + expect(await connectionB.stream.first, 'bar'); + }); + + test('can close from the server', () async { + expect(handler.numberOfClients, 0); + await webdriver.get('http://localhost:${server.port}'); + var connection = await handler.connections.next; + expect(handler.numberOfClients, 1); + await connection.sink.close(); + await pumpEventQueue(); + expect(handler.numberOfClients, 0); + }); + + test('client reconnects after being disconnected', () async { + expect(handler.numberOfClients, 0); + await webdriver.get('http://localhost:${server.port}'); + var connection = await handler.connections.next; + expect(handler.numberOfClients, 1); + await connection.sink.close(); + await pumpEventQueue(); + expect(handler.numberOfClients, 0); + + // Ensure the client reconnects + await handler.connections.next; + }); + + test('can close from the client-side', () async { + expect(handler.numberOfClients, 0); + await webdriver.get('http://localhost:${server.port}'); + var connection = await handler.connections.next; + expect(handler.numberOfClients, 1); + + var closeButton = await webdriver.findElement(const By.tagName('button')); + await closeButton.click(); + + // Should complete since the connection is closed. + await connection.stream.drain(); + expect(handler.numberOfClients, 0); + }); + + test('cancelling the listener closes the connection', () async { + expect(handler.numberOfClients, 0); + await webdriver.get('http://localhost:${server.port}'); + var connection = await handler.connections.next; + expect(handler.numberOfClients, 1); + + var sub = connection.stream.listen((_) {}); + await sub.cancel(); + await pumpEventQueue(); + expect(handler.numberOfClients, 0); + }); + + test('disconnects when navigating away', () async { + await webdriver.get('http://localhost:${server.port}'); + expect(handler.numberOfClients, 1); + + await webdriver.get('chrome://version/'); + expect(handler.numberOfClients, 0); + }); + }); + + group('SSE with server keep-alive', () { + setUp(() async { + handler = + SseHandler(Uri.parse('/test'), keepAlive: const Duration(seconds: 5)); + + var cascade = shelf.Cascade() + .add(handler.handler) + .add(_faviconHandler) + .add(createStaticHandler('test/web', + listDirectories: true, defaultDocument: 'index.html')); + + server = await io.serve(cascade.handler, 'localhost', 0); + var capabilities = Capabilities.chrome + ..addAll({ + Capabilities.chromeOptions: { + 'args': ['--headless'] + } + }); + webdriver = await createDriver(desired: capabilities); + }); + + tearDown(() async { + await webdriver.quit(); + await server.close(); + }); + + test('client reconnect use the same connection', () async { + expect(handler.numberOfClients, 0); + await webdriver.get('http://localhost:${server.port}'); + var connection = await handler.connections.next; + expect(handler.numberOfClients, 1); + + // Close the underlying connection. + closeSink(connection); + // Ensure we can still round-trip data on the original connection and that + // the connection is no longer marked keep-alive once it's reconnected. + connection.sink.add('bar'); + var queue = StreamQueue(connection.stream); + expect(await queue.next, 'bar'); + + // Now check that we can reconnect multiple times. + closeSink(connection); + connection.sink.add('bar'); + expect(await queue.next, 'bar'); + expect(handler.numberOfClients, 1); + }); + + test('messages sent during disconnect arrive in-order', () async { + expect(handler.numberOfClients, 0); + await webdriver.get('http://localhost:${server.port}'); + var connection = await handler.connections.next; + expect(handler.numberOfClients, 1); + + // Close the underlying connection. + closeSink(connection); + connection.sink.add('one'); + connection.sink.add('two'); + await pumpEventQueue(); + + // Ensure there's still a connection. + expect(handler.numberOfClients, 1); + + // Ensure messages arrive in the same order + expect(await connection.stream.take(2).toList(), equals(['one', 'two'])); + }); + + test('explicit shutdown does not wait for keepAlive', () async { + expect(handler.numberOfClients, 0); + await webdriver.get('http://localhost:${server.port}'); + await handler.connections.next; + expect(handler.numberOfClients, 1); + + // Close the underlying connection. + handler.shutdown(); + + // Wait for a short period to allow the connection to close, but not + // long enough that the 30second keep-alive may have expired. + var maxPumps = 50; + while (handler.numberOfClients > 0 && maxPumps-- > 0) { + await pumpEventQueue(times: 1); + } + + // Ensure there are not connected clients. + expect(handler.numberOfClients, 0); + }); + }, timeout: const Timeout(Duration(seconds: 120))); +} + +FutureOr _faviconHandler(shelf.Request request) { + if (request.url.path.endsWith('favicon.ico')) { + return shelf.Response.ok(''); + } + return shelf.Response.notFound(''); +} diff --git a/pkgs/sse/test/web/index.dart b/pkgs/sse/test/web/index.dart new file mode 100644 index 000000000..c4d78cd9a --- /dev/null +++ b/pkgs/sse/test/web/index.dart @@ -0,0 +1,25 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:sse/client/sse_client.dart'; +import 'package:web/web.dart'; + +void main() { + var channel = SseClient('/test'); + + document.querySelector('button')!.onClick.listen((_) { + channel.sink.close(); + }); + + channel.stream.listen((s) { + if (s.startsWith('send ')) { + var count = int.parse(s.split(' ').last); + for (var i = 0; i < count; i++) { + channel.sink.add('$i'); + } + } else { + channel.sink.add(s); + } + }); +} diff --git a/pkgs/sse/test/web/index.dart.js b/pkgs/sse/test/web/index.dart.js new file mode 100644 index 000000000..e1b37b927 --- /dev/null +++ b/pkgs/sse/test/web/index.dart.js @@ -0,0 +1,8851 @@ +// Generated by dart2js (NullSafetyMode.sound, csp, intern-composite-values), the Dart to JavaScript compiler version: 3.4.0-157.0.dev. +// The code supports the following hooks: +// dartPrint(message): +// if this function is defined it is called instead of the Dart [print] +// method. +// +// dartMainRunner(main, args): +// if this function is defined, the Dart [main] method will not be invoked +// directly. Instead, a closure that will invoke [main], and its arguments +// [args] is passed to [dartMainRunner]. +// +// dartDeferredLibraryLoader(uri, successCallback, errorCallback, loadId, loadPriority): +// if this function is defined, it will be called when a deferred library +// is loaded. It should load and eval the javascript of `uri`, and call +// successCallback. If it fails to do so, it should call errorCallback with +// an error. The loadId argument is the deferred import that resulted in +// this uri being loaded. The loadPriority argument is the priority the +// library should be loaded with as specified in the code via the +// load-priority annotation (0: normal, 1: high). +// dartDeferredLibraryMultiLoader(uris, successCallback, errorCallback, loadId, loadPriority): +// if this function is defined, it will be called when a deferred library +// is loaded. It should load and eval the javascript of every URI in `uris`, +// and call successCallback. If it fails to do so, it should call +// errorCallback with an error. The loadId argument is the deferred import +// that resulted in this uri being loaded. The loadPriority argument is the +// priority the library should be loaded with as specified in the code via +// the load-priority annotation (0: normal, 1: high). +// +// dartCallInstrumentation(id, qualifiedName): +// if this function is defined, it will be called at each entry of a +// method or constructor. Used only when compiling programs with +// --experiment-call-instrumentation. +(function dartProgram() { + function copyProperties(from, to) { + var keys = Object.keys(from); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + to[key] = from[key]; + } + } + function mixinPropertiesHard(from, to) { + var keys = Object.keys(from); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + if (!to.hasOwnProperty(key)) { + to[key] = from[key]; + } + } + } + function mixinPropertiesEasy(from, to) { + Object.assign(to, from); + } + var supportsDirectProtoAccess = function() { + var cls = function() { + }; + cls.prototype = {p: {}}; + var object = new cls(); + if (!(Object.getPrototypeOf(object) && Object.getPrototypeOf(object).p === cls.prototype.p)) + return false; + try { + if (typeof navigator != "undefined" && typeof navigator.userAgent == "string" && navigator.userAgent.indexOf("Chrome/") >= 0) + return true; + if (typeof version == "function" && version.length == 0) { + var v = version(); + if (/^\d+\.\d+\.\d+\.\d+$/.test(v)) + return true; + } + } catch (_) { + } + return false; + }(); + function inherit(cls, sup) { + cls.prototype.constructor = cls; + cls.prototype["$is" + cls.name] = cls; + if (sup != null) { + if (supportsDirectProtoAccess) { + Object.setPrototypeOf(cls.prototype, sup.prototype); + return; + } + var clsPrototype = Object.create(sup.prototype); + copyProperties(cls.prototype, clsPrototype); + cls.prototype = clsPrototype; + } + } + function inheritMany(sup, classes) { + for (var i = 0; i < classes.length; i++) { + inherit(classes[i], sup); + } + } + function mixinEasy(cls, mixin) { + mixinPropertiesEasy(mixin.prototype, cls.prototype); + cls.prototype.constructor = cls; + } + function mixinHard(cls, mixin) { + mixinPropertiesHard(mixin.prototype, cls.prototype); + cls.prototype.constructor = cls; + } + function lazy(holder, name, getterName, initializer) { + var uninitializedSentinel = holder; + holder[name] = uninitializedSentinel; + holder[getterName] = function() { + if (holder[name] === uninitializedSentinel) { + holder[name] = initializer(); + } + holder[getterName] = function() { + return this[name]; + }; + return holder[name]; + }; + } + function lazyFinal(holder, name, getterName, initializer) { + var uninitializedSentinel = holder; + holder[name] = uninitializedSentinel; + holder[getterName] = function() { + if (holder[name] === uninitializedSentinel) { + var value = initializer(); + if (holder[name] !== uninitializedSentinel) { + A.throwLateFieldADI(name); + } + holder[name] = value; + } + var finalValue = holder[name]; + holder[getterName] = function() { + return finalValue; + }; + return finalValue; + }; + } + function makeConstList(list) { + list.immutable$list = Array; + list.fixed$length = Array; + return list; + } + function convertToFastObject(properties) { + function t() { + } + t.prototype = properties; + new t(); + return properties; + } + function convertAllToFastObject(arrayOfObjects) { + for (var i = 0; i < arrayOfObjects.length; ++i) { + convertToFastObject(arrayOfObjects[i]); + } + } + var functionCounter = 0; + function instanceTearOffGetter(isIntercepted, parameters) { + var cache = null; + return isIntercepted ? function(receiver) { + if (cache === null) + cache = A.closureFromTearOff(parameters); + return new cache(receiver, this); + } : function() { + if (cache === null) + cache = A.closureFromTearOff(parameters); + return new cache(this, null); + }; + } + function staticTearOffGetter(parameters) { + var cache = null; + return function() { + if (cache === null) + cache = A.closureFromTearOff(parameters).prototype; + return cache; + }; + } + var typesOffset = 0; + function tearOffParameters(container, isStatic, isIntercepted, requiredParameterCount, optionalParameterDefaultValues, callNames, funsOrNames, funType, applyIndex, needsDirectAccess) { + if (typeof funType == "number") { + funType += typesOffset; + } + return {co: container, iS: isStatic, iI: isIntercepted, rC: requiredParameterCount, dV: optionalParameterDefaultValues, cs: callNames, fs: funsOrNames, fT: funType, aI: applyIndex || 0, nDA: needsDirectAccess}; + } + function installStaticTearOff(holder, getterName, requiredParameterCount, optionalParameterDefaultValues, callNames, funsOrNames, funType, applyIndex) { + var parameters = tearOffParameters(holder, true, false, requiredParameterCount, optionalParameterDefaultValues, callNames, funsOrNames, funType, applyIndex, false); + var getterFunction = staticTearOffGetter(parameters); + holder[getterName] = getterFunction; + } + function installInstanceTearOff(prototype, getterName, isIntercepted, requiredParameterCount, optionalParameterDefaultValues, callNames, funsOrNames, funType, applyIndex, needsDirectAccess) { + isIntercepted = !!isIntercepted; + var parameters = tearOffParameters(prototype, false, isIntercepted, requiredParameterCount, optionalParameterDefaultValues, callNames, funsOrNames, funType, applyIndex, !!needsDirectAccess); + var getterFunction = instanceTearOffGetter(isIntercepted, parameters); + prototype[getterName] = getterFunction; + } + function setOrUpdateInterceptorsByTag(newTags) { + var tags = init.interceptorsByTag; + if (!tags) { + init.interceptorsByTag = newTags; + return; + } + copyProperties(newTags, tags); + } + function setOrUpdateLeafTags(newTags) { + var tags = init.leafTags; + if (!tags) { + init.leafTags = newTags; + return; + } + copyProperties(newTags, tags); + } + function updateTypes(newTypes) { + var types = init.types; + var length = types.length; + types.push.apply(types, newTypes); + return length; + } + function updateHolder(holder, newHolder) { + copyProperties(newHolder, holder); + return holder; + } + var hunkHelpers = function() { + var mkInstance = function(isIntercepted, requiredParameterCount, optionalParameterDefaultValues, callNames, applyIndex) { + return function(container, getterName, name, funType) { + return installInstanceTearOff(container, getterName, isIntercepted, requiredParameterCount, optionalParameterDefaultValues, callNames, [name], funType, applyIndex, false); + }; + }, + mkStatic = function(requiredParameterCount, optionalParameterDefaultValues, callNames, applyIndex) { + return function(container, getterName, name, funType) { + return installStaticTearOff(container, getterName, requiredParameterCount, optionalParameterDefaultValues, callNames, [name], funType, applyIndex); + }; + }; + return {inherit: inherit, inheritMany: inheritMany, mixin: mixinEasy, mixinHard: mixinHard, installStaticTearOff: installStaticTearOff, installInstanceTearOff: installInstanceTearOff, _instance_0u: mkInstance(0, 0, null, ["call$0"], 0), _instance_1u: mkInstance(0, 1, null, ["call$1"], 0), _instance_2u: mkInstance(0, 2, null, ["call$2"], 0), _instance_0i: mkInstance(1, 0, null, ["call$0"], 0), _instance_1i: mkInstance(1, 1, null, ["call$1"], 0), _instance_2i: mkInstance(1, 2, null, ["call$2"], 0), _static_0: mkStatic(0, null, ["call$0"], 0), _static_1: mkStatic(1, null, ["call$1"], 0), _static_2: mkStatic(2, null, ["call$2"], 0), makeConstList: makeConstList, lazy: lazy, lazyFinal: lazyFinal, updateHolder: updateHolder, convertToFastObject: convertToFastObject, updateTypes: updateTypes, setOrUpdateInterceptorsByTag: setOrUpdateInterceptorsByTag, setOrUpdateLeafTags: setOrUpdateLeafTags}; + }(); + function initializeDeferredHunk(hunk) { + typesOffset = init.types.length; + hunk(hunkHelpers, init, holders, $); + } + var J = { + makeDispatchRecord(interceptor, proto, extension, indexability) { + return {i: interceptor, p: proto, e: extension, x: indexability}; + }, + getNativeInterceptor(object) { + var proto, objectProto, $constructor, interceptor, t1, + record = object[init.dispatchPropertyName]; + if (record == null) + if ($.initNativeDispatchFlag == null) { + A.initNativeDispatch(); + record = object[init.dispatchPropertyName]; + } + if (record != null) { + proto = record.p; + if (false === proto) + return record.i; + if (true === proto) + return object; + objectProto = Object.getPrototypeOf(object); + if (proto === objectProto) + return record.i; + if (record.e === objectProto) + throw A.wrapException(A.UnimplementedError$("Return interceptor for " + A.S(proto(object, record)))); + } + $constructor = object.constructor; + if ($constructor == null) + interceptor = null; + else { + t1 = $._JS_INTEROP_INTERCEPTOR_TAG; + if (t1 == null) + t1 = $._JS_INTEROP_INTERCEPTOR_TAG = init.getIsolateTag("_$dart_js"); + interceptor = $constructor[t1]; + } + if (interceptor != null) + return interceptor; + interceptor = A.lookupAndCacheInterceptor(object); + if (interceptor != null) + return interceptor; + if (typeof object == "function") + return B.JavaScriptFunction_methods; + proto = Object.getPrototypeOf(object); + if (proto == null) + return B.PlainJavaScriptObject_methods; + if (proto === Object.prototype) + return B.PlainJavaScriptObject_methods; + if (typeof $constructor == "function") { + t1 = $._JS_INTEROP_INTERCEPTOR_TAG; + if (t1 == null) + t1 = $._JS_INTEROP_INTERCEPTOR_TAG = init.getIsolateTag("_$dart_js"); + Object.defineProperty($constructor, t1, {value: B.UnknownJavaScriptObject_methods, enumerable: false, writable: true, configurable: true}); + return B.UnknownJavaScriptObject_methods; + } + return B.UnknownJavaScriptObject_methods; + }, + JSArray_JSArray$fixed($length, $E) { + if ($length < 0 || $length > 4294967295) + throw A.wrapException(A.RangeError$range($length, 0, 4294967295, "length", null)); + return J.JSArray_JSArray$markFixed(new Array($length), $E); + }, + JSArray_JSArray$growable($length, $E) { + if ($length < 0) + throw A.wrapException(A.ArgumentError$("Length must be a non-negative integer: " + $length, null)); + return A._setArrayType(new Array($length), $E._eval$1("JSArray<0>")); + }, + JSArray_JSArray$markFixed(allocation, $E) { + return J.JSArray_markFixedList(A._setArrayType(allocation, $E._eval$1("JSArray<0>")), $E); + }, + JSArray_markFixedList(list, $T) { + list.fixed$length = Array; + return list; + }, + JSArray_markUnmodifiableList(list) { + list.fixed$length = Array; + list.immutable$list = Array; + return list; + }, + getInterceptor$(receiver) { + if (typeof receiver == "number") { + if (Math.floor(receiver) == receiver) + return J.JSInt.prototype; + return J.JSNumNotInt.prototype; + } + if (typeof receiver == "string") + return J.JSString.prototype; + if (receiver == null) + return J.JSNull.prototype; + if (typeof receiver == "boolean") + return J.JSBool.prototype; + if (Array.isArray(receiver)) + return J.JSArray.prototype; + if (typeof receiver != "object") { + if (typeof receiver == "function") + return J.JavaScriptFunction.prototype; + if (typeof receiver == "symbol") + return J.JavaScriptSymbol.prototype; + if (typeof receiver == "bigint") + return J.JavaScriptBigInt.prototype; + return receiver; + } + if (receiver instanceof A.Object) + return receiver; + return J.getNativeInterceptor(receiver); + }, + getInterceptor$asx(receiver) { + if (typeof receiver == "string") + return J.JSString.prototype; + if (receiver == null) + return receiver; + if (Array.isArray(receiver)) + return J.JSArray.prototype; + if (typeof receiver != "object") { + if (typeof receiver == "function") + return J.JavaScriptFunction.prototype; + if (typeof receiver == "symbol") + return J.JavaScriptSymbol.prototype; + if (typeof receiver == "bigint") + return J.JavaScriptBigInt.prototype; + return receiver; + } + if (receiver instanceof A.Object) + return receiver; + return J.getNativeInterceptor(receiver); + }, + getInterceptor$ax(receiver) { + if (receiver == null) + return receiver; + if (Array.isArray(receiver)) + return J.JSArray.prototype; + if (typeof receiver != "object") { + if (typeof receiver == "function") + return J.JavaScriptFunction.prototype; + if (typeof receiver == "symbol") + return J.JavaScriptSymbol.prototype; + if (typeof receiver == "bigint") + return J.JavaScriptBigInt.prototype; + return receiver; + } + if (receiver instanceof A.Object) + return receiver; + return J.getNativeInterceptor(receiver); + }, + getInterceptor$s(receiver) { + if (typeof receiver == "string") + return J.JSString.prototype; + if (receiver == null) + return receiver; + if (!(receiver instanceof A.Object)) + return J.UnknownJavaScriptObject.prototype; + return receiver; + }, + get$hashCode$(receiver) { + return J.getInterceptor$(receiver).get$hashCode(receiver); + }, + get$iterator$ax(receiver) { + return J.getInterceptor$ax(receiver).get$iterator(receiver); + }, + get$length$asx(receiver) { + return J.getInterceptor$asx(receiver).get$length(receiver); + }, + get$runtimeType$(receiver) { + return J.getInterceptor$(receiver).get$runtimeType(receiver); + }, + $eq$(receiver, a0) { + if (receiver == null) + return a0 == null; + if (typeof receiver != "object") + return a0 != null && receiver === a0; + return J.getInterceptor$(receiver).$eq(receiver, a0); + }, + matchAsPrefix$2$s(receiver, a0, a1) { + return J.getInterceptor$s(receiver).matchAsPrefix$2(receiver, a0, a1); + }, + noSuchMethod$1$(receiver, a0) { + return J.getInterceptor$(receiver).noSuchMethod$1(receiver, a0); + }, + toString$0$(receiver) { + return J.getInterceptor$(receiver).toString$0(receiver); + }, + Interceptor: function Interceptor() { + }, + JSBool: function JSBool() { + }, + JSNull: function JSNull() { + }, + JavaScriptObject: function JavaScriptObject() { + }, + LegacyJavaScriptObject: function LegacyJavaScriptObject() { + }, + PlainJavaScriptObject: function PlainJavaScriptObject() { + }, + UnknownJavaScriptObject: function UnknownJavaScriptObject() { + }, + JavaScriptFunction: function JavaScriptFunction() { + }, + JavaScriptBigInt: function JavaScriptBigInt() { + }, + JavaScriptSymbol: function JavaScriptSymbol() { + }, + JSArray: function JSArray(t0) { + this.$ti = t0; + }, + JSUnmodifiableArray: function JSUnmodifiableArray(t0) { + this.$ti = t0; + }, + ArrayIterator: function ArrayIterator(t0, t1, t2) { + var _ = this; + _._iterable = t0; + _._length = t1; + _._index = 0; + _._current = null; + _.$ti = t2; + }, + JSNumber: function JSNumber() { + }, + JSInt: function JSInt() { + }, + JSNumNotInt: function JSNumNotInt() { + }, + JSString: function JSString() { + } + }, + A = {JS_CONST: function JS_CONST() { + }, + checkNotNullable(value, $name, $T) { + return value; + }, + isToStringVisiting(object) { + var t1, i; + for (t1 = $.toStringVisiting.length, i = 0; i < t1; ++i) + if (object === $.toStringVisiting[i]) + return true; + return false; + }, + IterableElementError_noElement() { + return new A.StateError("No element"); + }, + IterableElementError_tooFew() { + return new A.StateError("Too few elements"); + }, + LateError: function LateError(t0) { + this._message = t0; + }, + nullFuture_closure: function nullFuture_closure() { + }, + EfficientLengthIterable: function EfficientLengthIterable() { + }, + ListIterable: function ListIterable() { + }, + ListIterator: function ListIterator(t0, t1, t2) { + var _ = this; + _.__internal$_iterable = t0; + _.__internal$_length = t1; + _.__internal$_index = 0; + _.__internal$_current = null; + _.$ti = t2; + }, + FixedLengthListMixin: function FixedLengthListMixin() { + }, + Symbol: function Symbol(t0) { + this._name = t0; + }, + unminifyOrTag(rawClassName) { + var preserved = init.mangledGlobalNames[rawClassName]; + if (preserved != null) + return preserved; + return rawClassName; + }, + isJsIndexable(object, record) { + var result; + if (record != null) { + result = record.x; + if (result != null) + return result; + } + return type$.JavaScriptIndexingBehavior_dynamic._is(object); + }, + S(value) { + var result; + if (typeof value == "string") + return value; + if (typeof value == "number") { + if (value !== 0) + return "" + value; + } else if (true === value) + return "true"; + else if (false === value) + return "false"; + else if (value == null) + return "null"; + result = J.toString$0$(value); + return result; + }, + Primitives_objectHashCode(object) { + var hash, + property = $.Primitives__identityHashCodeProperty; + if (property == null) + property = $.Primitives__identityHashCodeProperty = Symbol("identityHashCode"); + hash = object[property]; + if (hash == null) { + hash = Math.random() * 0x3fffffff | 0; + object[property] = hash; + } + return hash; + }, + Primitives_parseInt(source, radix) { + var decimalMatch, maxCharCode, digitsPart, t1, i, _null = null, + match = /^\s*[+-]?((0x[a-f0-9]+)|(\d+)|([a-z0-9]+))\s*$/i.exec(source); + if (match == null) + return _null; + if (3 >= match.length) + return A.ioore(match, 3); + decimalMatch = match[3]; + if (radix == null) { + if (decimalMatch != null) + return parseInt(source, 10); + if (match[2] != null) + return parseInt(source, 16); + return _null; + } + if (radix < 2 || radix > 36) + throw A.wrapException(A.RangeError$range(radix, 2, 36, "radix", _null)); + if (radix === 10 && decimalMatch != null) + return parseInt(source, 10); + if (radix < 10 || decimalMatch == null) { + maxCharCode = radix <= 10 ? 47 + radix : 86 + radix; + digitsPart = match[1]; + for (t1 = digitsPart.length, i = 0; i < t1; ++i) + if ((digitsPart.charCodeAt(i) | 32) > maxCharCode) + return _null; + } + return parseInt(source, radix); + }, + Primitives_objectTypeName(object) { + return A.Primitives__objectTypeNameNewRti(object); + }, + Primitives__objectTypeNameNewRti(object) { + var interceptor, dispatchName, $constructor, constructorName; + if (object instanceof A.Object) + return A._rtiToString(A.instanceType(object), null); + interceptor = J.getInterceptor$(object); + if (interceptor === B.Interceptor_methods || interceptor === B.JavaScriptObject_methods || type$.UnknownJavaScriptObject._is(object)) { + dispatchName = B.C_JS_CONST(object); + if (dispatchName !== "Object" && dispatchName !== "") + return dispatchName; + $constructor = object.constructor; + if (typeof $constructor == "function") { + constructorName = $constructor.name; + if (typeof constructorName == "string" && constructorName !== "Object" && constructorName !== "") + return constructorName; + } + } + return A._rtiToString(A.instanceType(object), null); + }, + Primitives_safeToString(object) { + if (typeof object == "number" || A._isBool(object)) + return J.toString$0$(object); + if (typeof object == "string") + return JSON.stringify(object); + if (object instanceof A.Closure) + return object.toString$0(0); + return "Instance of '" + A.Primitives_objectTypeName(object) + "'"; + }, + Primitives_stringFromCharCode(charCode) { + var bits; + if (0 <= charCode) { + if (charCode <= 65535) + return String.fromCharCode(charCode); + if (charCode <= 1114111) { + bits = charCode - 65536; + return String.fromCharCode((B.JSInt_methods._shrOtherPositive$1(bits, 10) | 55296) >>> 0, bits & 1023 | 56320); + } + } + throw A.wrapException(A.RangeError$range(charCode, 0, 1114111, null, null)); + }, + Primitives_lazyAsJsDate(receiver) { + if (receiver.date === void 0) + receiver.date = new Date(receiver._value); + return receiver.date; + }, + Primitives_getYear(receiver) { + return receiver.isUtc ? A.Primitives_lazyAsJsDate(receiver).getUTCFullYear() + 0 : A.Primitives_lazyAsJsDate(receiver).getFullYear() + 0; + }, + Primitives_getMonth(receiver) { + return receiver.isUtc ? A.Primitives_lazyAsJsDate(receiver).getUTCMonth() + 1 : A.Primitives_lazyAsJsDate(receiver).getMonth() + 1; + }, + Primitives_getDay(receiver) { + return receiver.isUtc ? A.Primitives_lazyAsJsDate(receiver).getUTCDate() + 0 : A.Primitives_lazyAsJsDate(receiver).getDate() + 0; + }, + Primitives_getHours(receiver) { + return receiver.isUtc ? A.Primitives_lazyAsJsDate(receiver).getUTCHours() + 0 : A.Primitives_lazyAsJsDate(receiver).getHours() + 0; + }, + Primitives_getMinutes(receiver) { + return receiver.isUtc ? A.Primitives_lazyAsJsDate(receiver).getUTCMinutes() + 0 : A.Primitives_lazyAsJsDate(receiver).getMinutes() + 0; + }, + Primitives_getSeconds(receiver) { + return receiver.isUtc ? A.Primitives_lazyAsJsDate(receiver).getUTCSeconds() + 0 : A.Primitives_lazyAsJsDate(receiver).getSeconds() + 0; + }, + Primitives_getMilliseconds(receiver) { + return receiver.isUtc ? A.Primitives_lazyAsJsDate(receiver).getUTCMilliseconds() + 0 : A.Primitives_lazyAsJsDate(receiver).getMilliseconds() + 0; + }, + Primitives_functionNoSuchMethod($function, positionalArguments, namedArguments) { + var $arguments, namedArgumentList, t1 = {}; + t1.argumentCount = 0; + $arguments = []; + namedArgumentList = []; + t1.argumentCount = positionalArguments.length; + B.JSArray_methods.addAll$1($arguments, positionalArguments); + t1.names = ""; + if (namedArguments != null && namedArguments.__js_helper$_length !== 0) + namedArguments.forEach$1(0, new A.Primitives_functionNoSuchMethod_closure(t1, namedArgumentList, $arguments)); + return J.noSuchMethod$1$($function, new A.JSInvocationMirror(B.Symbol_call, 0, $arguments, namedArgumentList, 0)); + }, + Primitives_applyFunction($function, positionalArguments, namedArguments) { + var t1, argumentCount, jsStub; + if (Array.isArray(positionalArguments)) + t1 = namedArguments == null || namedArguments.__js_helper$_length === 0; + else + t1 = false; + if (t1) { + argumentCount = positionalArguments.length; + if (argumentCount === 0) { + if (!!$function.call$0) + return $function.call$0(); + } else if (argumentCount === 1) { + if (!!$function.call$1) + return $function.call$1(positionalArguments[0]); + } else if (argumentCount === 2) { + if (!!$function.call$2) + return $function.call$2(positionalArguments[0], positionalArguments[1]); + } else if (argumentCount === 3) { + if (!!$function.call$3) + return $function.call$3(positionalArguments[0], positionalArguments[1], positionalArguments[2]); + } else if (argumentCount === 4) { + if (!!$function.call$4) + return $function.call$4(positionalArguments[0], positionalArguments[1], positionalArguments[2], positionalArguments[3]); + } else if (argumentCount === 5) + if (!!$function.call$5) + return $function.call$5(positionalArguments[0], positionalArguments[1], positionalArguments[2], positionalArguments[3], positionalArguments[4]); + jsStub = $function["call" + "$" + argumentCount]; + if (jsStub != null) + return jsStub.apply($function, positionalArguments); + } + return A.Primitives__generalApplyFunction($function, positionalArguments, namedArguments); + }, + Primitives__generalApplyFunction($function, positionalArguments, namedArguments) { + var defaultValuesClosure, t1, defaultValues, interceptor, jsFunction, maxArguments, missingDefaults, keys, _i, defaultValue, used, key, + $arguments = Array.isArray(positionalArguments) ? positionalArguments : A.List_List$of(positionalArguments, true, type$.dynamic), + argumentCount = $arguments.length, + requiredParameterCount = $function.$requiredArgCount; + if (argumentCount < requiredParameterCount) + return A.Primitives_functionNoSuchMethod($function, $arguments, namedArguments); + defaultValuesClosure = $function.$defaultValues; + t1 = defaultValuesClosure == null; + defaultValues = !t1 ? defaultValuesClosure() : null; + interceptor = J.getInterceptor$($function); + jsFunction = interceptor["call*"]; + if (typeof jsFunction == "string") + jsFunction = interceptor[jsFunction]; + if (t1) { + if (namedArguments != null && namedArguments.__js_helper$_length !== 0) + return A.Primitives_functionNoSuchMethod($function, $arguments, namedArguments); + if (argumentCount === requiredParameterCount) + return jsFunction.apply($function, $arguments); + return A.Primitives_functionNoSuchMethod($function, $arguments, namedArguments); + } + if (Array.isArray(defaultValues)) { + if (namedArguments != null && namedArguments.__js_helper$_length !== 0) + return A.Primitives_functionNoSuchMethod($function, $arguments, namedArguments); + maxArguments = requiredParameterCount + defaultValues.length; + if (argumentCount > maxArguments) + return A.Primitives_functionNoSuchMethod($function, $arguments, null); + if (argumentCount < maxArguments) { + missingDefaults = defaultValues.slice(argumentCount - requiredParameterCount); + if ($arguments === positionalArguments) + $arguments = A.List_List$of($arguments, true, type$.dynamic); + B.JSArray_methods.addAll$1($arguments, missingDefaults); + } + return jsFunction.apply($function, $arguments); + } else { + if (argumentCount > requiredParameterCount) + return A.Primitives_functionNoSuchMethod($function, $arguments, namedArguments); + if ($arguments === positionalArguments) + $arguments = A.List_List$of($arguments, true, type$.dynamic); + keys = Object.keys(defaultValues); + if (namedArguments == null) + for (t1 = keys.length, _i = 0; _i < keys.length; keys.length === t1 || (0, A.throwConcurrentModificationError)(keys), ++_i) { + defaultValue = defaultValues[A._asString(keys[_i])]; + if (B.C__Required === defaultValue) + return A.Primitives_functionNoSuchMethod($function, $arguments, namedArguments); + B.JSArray_methods.add$1($arguments, defaultValue); + } + else { + for (t1 = keys.length, used = 0, _i = 0; _i < keys.length; keys.length === t1 || (0, A.throwConcurrentModificationError)(keys), ++_i) { + key = A._asString(keys[_i]); + if (namedArguments.containsKey$1(key)) { + ++used; + B.JSArray_methods.add$1($arguments, namedArguments.$index(0, key)); + } else { + defaultValue = defaultValues[key]; + if (B.C__Required === defaultValue) + return A.Primitives_functionNoSuchMethod($function, $arguments, namedArguments); + B.JSArray_methods.add$1($arguments, defaultValue); + } + } + if (used !== namedArguments.__js_helper$_length) + return A.Primitives_functionNoSuchMethod($function, $arguments, namedArguments); + } + return jsFunction.apply($function, $arguments); + } + }, + ioore(receiver, index) { + if (receiver == null) + J.get$length$asx(receiver); + throw A.wrapException(A.diagnoseIndexError(receiver, index)); + }, + diagnoseIndexError(indexable, index) { + var $length, _s5_ = "index"; + if (!A._isInt(index)) + return new A.ArgumentError(true, index, _s5_, null); + $length = A._asInt(J.get$length$asx(indexable)); + if (index < 0 || index >= $length) + return A.IndexError$withLength(index, $length, indexable, null, _s5_); + return A.RangeError$value(index, _s5_); + }, + wrapException(ex) { + return A.initializeExceptionWrapper(new Error(), ex); + }, + initializeExceptionWrapper(wrapper, ex) { + var t1; + if (ex == null) + ex = new A.TypeError(); + wrapper.dartException = ex; + t1 = A.toStringWrapper; + if ("defineProperty" in Object) { + Object.defineProperty(wrapper, "message", {get: t1}); + wrapper.name = ""; + } else + wrapper.toString = t1; + return wrapper; + }, + toStringWrapper() { + return J.toString$0$(this.dartException); + }, + throwExpression(ex) { + throw A.wrapException(ex); + }, + throwExpressionWithWrapper(ex, wrapper) { + throw A.initializeExceptionWrapper(wrapper, ex); + }, + throwConcurrentModificationError(collection) { + throw A.wrapException(A.ConcurrentModificationError$(collection)); + }, + TypeErrorDecoder_extractPattern(message) { + var match, $arguments, argumentsExpr, expr, method, receiver; + message = A.quoteStringForRegExp(message.replace(String({}), "$receiver$")); + match = message.match(/\\\$[a-zA-Z]+\\\$/g); + if (match == null) + match = A._setArrayType([], type$.JSArray_String); + $arguments = match.indexOf("\\$arguments\\$"); + argumentsExpr = match.indexOf("\\$argumentsExpr\\$"); + expr = match.indexOf("\\$expr\\$"); + method = match.indexOf("\\$method\\$"); + receiver = match.indexOf("\\$receiver\\$"); + return new A.TypeErrorDecoder(message.replace(new RegExp("\\\\\\$arguments\\\\\\$", "g"), "((?:x|[^x])*)").replace(new RegExp("\\\\\\$argumentsExpr\\\\\\$", "g"), "((?:x|[^x])*)").replace(new RegExp("\\\\\\$expr\\\\\\$", "g"), "((?:x|[^x])*)").replace(new RegExp("\\\\\\$method\\\\\\$", "g"), "((?:x|[^x])*)").replace(new RegExp("\\\\\\$receiver\\\\\\$", "g"), "((?:x|[^x])*)"), $arguments, argumentsExpr, expr, method, receiver); + }, + TypeErrorDecoder_provokeCallErrorOn(expression) { + return function($expr$) { + var $argumentsExpr$ = "$arguments$"; + try { + $expr$.$method$($argumentsExpr$); + } catch (e) { + return e.message; + } + }(expression); + }, + TypeErrorDecoder_provokePropertyErrorOn(expression) { + return function($expr$) { + try { + $expr$.$method$; + } catch (e) { + return e.message; + } + }(expression); + }, + JsNoSuchMethodError$(_message, match) { + var t1 = match == null, + t2 = t1 ? null : match.method; + return new A.JsNoSuchMethodError(_message, t2, t1 ? null : match.receiver); + }, + unwrapException(ex) { + var t1; + if (ex == null) + return new A.NullThrownFromJavaScriptException(ex); + if (ex instanceof A.ExceptionAndStackTrace) { + t1 = ex.dartException; + return A.saveStackTrace(ex, t1 == null ? type$.Object._as(t1) : t1); + } + if (typeof ex !== "object") + return ex; + if ("dartException" in ex) + return A.saveStackTrace(ex, ex.dartException); + return A._unwrapNonDartException(ex); + }, + saveStackTrace(ex, error) { + if (type$.Error._is(error)) + if (error.$thrownJsError == null) + error.$thrownJsError = ex; + return error; + }, + _unwrapNonDartException(ex) { + var message, number, ieErrorCode, nsme, notClosure, nullCall, nullLiteralCall, undefCall, undefLiteralCall, nullProperty, undefProperty, undefLiteralProperty, match; + if (!("message" in ex)) + return ex; + message = ex.message; + if ("number" in ex && typeof ex.number == "number") { + number = ex.number; + ieErrorCode = number & 65535; + if ((B.JSInt_methods._shrOtherPositive$1(number, 16) & 8191) === 10) + switch (ieErrorCode) { + case 438: + return A.saveStackTrace(ex, A.JsNoSuchMethodError$(A.S(message) + " (Error " + ieErrorCode + ")", null)); + case 445: + case 5007: + A.S(message); + return A.saveStackTrace(ex, new A.NullError()); + } + } + if (ex instanceof TypeError) { + nsme = $.$get$TypeErrorDecoder_noSuchMethodPattern(); + notClosure = $.$get$TypeErrorDecoder_notClosurePattern(); + nullCall = $.$get$TypeErrorDecoder_nullCallPattern(); + nullLiteralCall = $.$get$TypeErrorDecoder_nullLiteralCallPattern(); + undefCall = $.$get$TypeErrorDecoder_undefinedCallPattern(); + undefLiteralCall = $.$get$TypeErrorDecoder_undefinedLiteralCallPattern(); + nullProperty = $.$get$TypeErrorDecoder_nullPropertyPattern(); + $.$get$TypeErrorDecoder_nullLiteralPropertyPattern(); + undefProperty = $.$get$TypeErrorDecoder_undefinedPropertyPattern(); + undefLiteralProperty = $.$get$TypeErrorDecoder_undefinedLiteralPropertyPattern(); + match = nsme.matchTypeError$1(message); + if (match != null) + return A.saveStackTrace(ex, A.JsNoSuchMethodError$(A._asString(message), match)); + else { + match = notClosure.matchTypeError$1(message); + if (match != null) { + match.method = "call"; + return A.saveStackTrace(ex, A.JsNoSuchMethodError$(A._asString(message), match)); + } else if (nullCall.matchTypeError$1(message) != null || nullLiteralCall.matchTypeError$1(message) != null || undefCall.matchTypeError$1(message) != null || undefLiteralCall.matchTypeError$1(message) != null || nullProperty.matchTypeError$1(message) != null || nullLiteralCall.matchTypeError$1(message) != null || undefProperty.matchTypeError$1(message) != null || undefLiteralProperty.matchTypeError$1(message) != null) { + A._asString(message); + return A.saveStackTrace(ex, new A.NullError()); + } + } + return A.saveStackTrace(ex, new A.UnknownJsTypeError(typeof message == "string" ? message : "")); + } + if (ex instanceof RangeError) { + if (typeof message == "string" && message.indexOf("call stack") !== -1) + return new A.StackOverflowError(); + message = function(ex) { + try { + return String(ex); + } catch (e) { + } + return null; + }(ex); + return A.saveStackTrace(ex, new A.ArgumentError(false, null, null, typeof message == "string" ? message.replace(/^RangeError:\s*/, "") : message)); + } + if (typeof InternalError == "function" && ex instanceof InternalError) + if (typeof message == "string" && message === "too much recursion") + return new A.StackOverflowError(); + return ex; + }, + getTraceFromException(exception) { + var trace; + if (exception instanceof A.ExceptionAndStackTrace) + return exception.stackTrace; + if (exception == null) + return new A._StackTrace(exception); + trace = exception.$cachedTrace; + if (trace != null) + return trace; + trace = new A._StackTrace(exception); + if (typeof exception === "object") + exception.$cachedTrace = trace; + return trace; + }, + objectHashCode(object) { + if (object == null) + return J.get$hashCode$(object); + if (typeof object == "object") + return A.Primitives_objectHashCode(object); + return J.get$hashCode$(object); + }, + _invokeClosure(closure, numberOfArguments, arg1, arg2, arg3, arg4) { + type$.Function._as(closure); + switch (A._asInt(numberOfArguments)) { + case 0: + return closure.call$0(); + case 1: + return closure.call$1(arg1); + case 2: + return closure.call$2(arg1, arg2); + case 3: + return closure.call$3(arg1, arg2, arg3); + case 4: + return closure.call$4(arg1, arg2, arg3, arg4); + } + throw A.wrapException(new A._Exception("Unsupported number of arguments for wrapped closure")); + }, + convertDartClosureToJS(closure, arity) { + var $function = closure.$identity; + if (!!$function) + return $function; + $function = A.convertDartClosureToJSUncached(closure, arity); + closure.$identity = $function; + return $function; + }, + convertDartClosureToJSUncached(closure, arity) { + var entry; + switch (arity) { + case 0: + entry = closure.call$0; + break; + case 1: + entry = closure.call$1; + break; + case 2: + entry = closure.call$2; + break; + case 3: + entry = closure.call$3; + break; + case 4: + entry = closure.call$4; + break; + default: + entry = null; + } + if (entry != null) + return entry.bind(closure); + return function(closure, arity, invoke) { + return function(a1, a2, a3, a4) { + return invoke(closure, arity, a1, a2, a3, a4); + }; + }(closure, arity, A._invokeClosure); + }, + Closure_fromTearOff(parameters) { + var $prototype, $constructor, t2, trampoline, applyTrampoline, i, stub, stub0, stubName, stubCallName, + container = parameters.co, + isStatic = parameters.iS, + isIntercepted = parameters.iI, + needsDirectAccess = parameters.nDA, + applyTrampolineIndex = parameters.aI, + funsOrNames = parameters.fs, + callNames = parameters.cs, + $name = funsOrNames[0], + callName = callNames[0], + $function = container[$name], + t1 = parameters.fT; + t1.toString; + $prototype = isStatic ? Object.create(new A.StaticClosure().constructor.prototype) : Object.create(new A.BoundClosure(null, null).constructor.prototype); + $prototype.$initialize = $prototype.constructor; + $constructor = isStatic ? function static_tear_off() { + this.$initialize(); + } : function tear_off(a, b) { + this.$initialize(a, b); + }; + $prototype.constructor = $constructor; + $constructor.prototype = $prototype; + $prototype.$_name = $name; + $prototype.$_target = $function; + t2 = !isStatic; + if (t2) + trampoline = A.Closure_forwardCallTo($name, $function, isIntercepted, needsDirectAccess); + else { + $prototype.$static_name = $name; + trampoline = $function; + } + $prototype.$signature = A.Closure__computeSignatureFunctionNewRti(t1, isStatic, isIntercepted); + $prototype[callName] = trampoline; + for (applyTrampoline = trampoline, i = 1; i < funsOrNames.length; ++i) { + stub = funsOrNames[i]; + if (typeof stub == "string") { + stub0 = container[stub]; + stubName = stub; + stub = stub0; + } else + stubName = ""; + stubCallName = callNames[i]; + if (stubCallName != null) { + if (t2) + stub = A.Closure_forwardCallTo(stubName, stub, isIntercepted, needsDirectAccess); + $prototype[stubCallName] = stub; + } + if (i === applyTrampolineIndex) + applyTrampoline = stub; + } + $prototype["call*"] = applyTrampoline; + $prototype.$requiredArgCount = parameters.rC; + $prototype.$defaultValues = parameters.dV; + return $constructor; + }, + Closure__computeSignatureFunctionNewRti(functionType, isStatic, isIntercepted) { + if (typeof functionType == "number") + return functionType; + if (typeof functionType == "string") { + if (isStatic) + throw A.wrapException("Cannot compute signature for static tearoff."); + return function(recipe, evalOnReceiver) { + return function() { + return evalOnReceiver(this, recipe); + }; + }(functionType, A.BoundClosure_evalRecipe); + } + throw A.wrapException("Error in functionType of tearoff"); + }, + Closure_cspForwardCall(arity, needsDirectAccess, stubName, $function) { + var getReceiver = A.BoundClosure_receiverOf; + switch (needsDirectAccess ? -1 : arity) { + case 0: + return function(entry, receiverOf) { + return function() { + return receiverOf(this)[entry](); + }; + }(stubName, getReceiver); + case 1: + return function(entry, receiverOf) { + return function(a) { + return receiverOf(this)[entry](a); + }; + }(stubName, getReceiver); + case 2: + return function(entry, receiverOf) { + return function(a, b) { + return receiverOf(this)[entry](a, b); + }; + }(stubName, getReceiver); + case 3: + return function(entry, receiverOf) { + return function(a, b, c) { + return receiverOf(this)[entry](a, b, c); + }; + }(stubName, getReceiver); + case 4: + return function(entry, receiverOf) { + return function(a, b, c, d) { + return receiverOf(this)[entry](a, b, c, d); + }; + }(stubName, getReceiver); + case 5: + return function(entry, receiverOf) { + return function(a, b, c, d, e) { + return receiverOf(this)[entry](a, b, c, d, e); + }; + }(stubName, getReceiver); + default: + return function(f, receiverOf) { + return function() { + return f.apply(receiverOf(this), arguments); + }; + }($function, getReceiver); + } + }, + Closure_forwardCallTo(stubName, $function, isIntercepted, needsDirectAccess) { + if (isIntercepted) + return A.Closure_forwardInterceptedCallTo(stubName, $function, needsDirectAccess); + return A.Closure_cspForwardCall($function.length, needsDirectAccess, stubName, $function); + }, + Closure_cspForwardInterceptedCall(arity, needsDirectAccess, stubName, $function) { + var getReceiver = A.BoundClosure_receiverOf, + getInterceptor = A.BoundClosure_interceptorOf; + switch (needsDirectAccess ? -1 : arity) { + case 0: + throw A.wrapException(new A.RuntimeError("Intercepted function with no arguments.")); + case 1: + return function(entry, interceptorOf, receiverOf) { + return function() { + return interceptorOf(this)[entry](receiverOf(this)); + }; + }(stubName, getInterceptor, getReceiver); + case 2: + return function(entry, interceptorOf, receiverOf) { + return function(a) { + return interceptorOf(this)[entry](receiverOf(this), a); + }; + }(stubName, getInterceptor, getReceiver); + case 3: + return function(entry, interceptorOf, receiverOf) { + return function(a, b) { + return interceptorOf(this)[entry](receiverOf(this), a, b); + }; + }(stubName, getInterceptor, getReceiver); + case 4: + return function(entry, interceptorOf, receiverOf) { + return function(a, b, c) { + return interceptorOf(this)[entry](receiverOf(this), a, b, c); + }; + }(stubName, getInterceptor, getReceiver); + case 5: + return function(entry, interceptorOf, receiverOf) { + return function(a, b, c, d) { + return interceptorOf(this)[entry](receiverOf(this), a, b, c, d); + }; + }(stubName, getInterceptor, getReceiver); + case 6: + return function(entry, interceptorOf, receiverOf) { + return function(a, b, c, d, e) { + return interceptorOf(this)[entry](receiverOf(this), a, b, c, d, e); + }; + }(stubName, getInterceptor, getReceiver); + default: + return function(f, interceptorOf, receiverOf) { + return function() { + var a = [receiverOf(this)]; + Array.prototype.push.apply(a, arguments); + return f.apply(interceptorOf(this), a); + }; + }($function, getInterceptor, getReceiver); + } + }, + Closure_forwardInterceptedCallTo(stubName, $function, needsDirectAccess) { + var arity, t1; + if ($.BoundClosure__interceptorFieldNameCache == null) + $.BoundClosure__interceptorFieldNameCache = A.BoundClosure__computeFieldNamed("interceptor"); + if ($.BoundClosure__receiverFieldNameCache == null) + $.BoundClosure__receiverFieldNameCache = A.BoundClosure__computeFieldNamed("receiver"); + arity = $function.length; + t1 = A.Closure_cspForwardInterceptedCall(arity, needsDirectAccess, stubName, $function); + return t1; + }, + closureFromTearOff(parameters) { + return A.Closure_fromTearOff(parameters); + }, + BoundClosure_evalRecipe(closure, recipe) { + return A._Universe_evalInEnvironment(init.typeUniverse, A.instanceType(closure._receiver), recipe); + }, + BoundClosure_receiverOf(closure) { + return closure._receiver; + }, + BoundClosure_interceptorOf(closure) { + return closure._interceptor; + }, + BoundClosure__computeFieldNamed(fieldName) { + var t1, i, $name, + template = new A.BoundClosure("receiver", "interceptor"), + names = J.JSArray_markFixedList(Object.getOwnPropertyNames(template), type$.nullable_Object); + for (t1 = names.length, i = 0; i < t1; ++i) { + $name = names[i]; + if (template[$name] === fieldName) + return $name; + } + throw A.wrapException(A.ArgumentError$("Field name " + fieldName + " not found.", null)); + }, + throwCyclicInit(staticName) { + throw A.wrapException(new A._CyclicInitializationError(staticName)); + }, + getIsolateAffinityTag($name) { + return init.getIsolateTag($name); + }, + lookupAndCacheInterceptor(obj) { + var interceptor, interceptorClass, altTag, mark, t1, + tag = A._asString($.getTagFunction.call$1(obj)), + record = $.dispatchRecordsForInstanceTags[tag]; + if (record != null) { + Object.defineProperty(obj, init.dispatchPropertyName, {value: record, enumerable: false, writable: true, configurable: true}); + return record.i; + } + interceptor = $.interceptorsForUncacheableTags[tag]; + if (interceptor != null) + return interceptor; + interceptorClass = init.interceptorsByTag[tag]; + if (interceptorClass == null) { + altTag = A._asStringQ($.alternateTagFunction.call$2(obj, tag)); + if (altTag != null) { + record = $.dispatchRecordsForInstanceTags[altTag]; + if (record != null) { + Object.defineProperty(obj, init.dispatchPropertyName, {value: record, enumerable: false, writable: true, configurable: true}); + return record.i; + } + interceptor = $.interceptorsForUncacheableTags[altTag]; + if (interceptor != null) + return interceptor; + interceptorClass = init.interceptorsByTag[altTag]; + tag = altTag; + } + } + if (interceptorClass == null) + return null; + interceptor = interceptorClass.prototype; + mark = tag[0]; + if (mark === "!") { + record = A.makeLeafDispatchRecord(interceptor); + $.dispatchRecordsForInstanceTags[tag] = record; + Object.defineProperty(obj, init.dispatchPropertyName, {value: record, enumerable: false, writable: true, configurable: true}); + return record.i; + } + if (mark === "~") { + $.interceptorsForUncacheableTags[tag] = interceptor; + return interceptor; + } + if (mark === "-") { + t1 = A.makeLeafDispatchRecord(interceptor); + Object.defineProperty(Object.getPrototypeOf(obj), init.dispatchPropertyName, {value: t1, enumerable: false, writable: true, configurable: true}); + return t1.i; + } + if (mark === "+") + return A.patchInteriorProto(obj, interceptor); + if (mark === "*") + throw A.wrapException(A.UnimplementedError$(tag)); + if (init.leafTags[tag] === true) { + t1 = A.makeLeafDispatchRecord(interceptor); + Object.defineProperty(Object.getPrototypeOf(obj), init.dispatchPropertyName, {value: t1, enumerable: false, writable: true, configurable: true}); + return t1.i; + } else + return A.patchInteriorProto(obj, interceptor); + }, + patchInteriorProto(obj, interceptor) { + var proto = Object.getPrototypeOf(obj); + Object.defineProperty(proto, init.dispatchPropertyName, {value: J.makeDispatchRecord(interceptor, proto, null, null), enumerable: false, writable: true, configurable: true}); + return interceptor; + }, + makeLeafDispatchRecord(interceptor) { + return J.makeDispatchRecord(interceptor, false, null, !!interceptor.$isJavaScriptIndexingBehavior); + }, + makeDefaultDispatchRecord(tag, interceptorClass, proto) { + var interceptor = interceptorClass.prototype; + if (init.leafTags[tag] === true) + return A.makeLeafDispatchRecord(interceptor); + else + return J.makeDispatchRecord(interceptor, proto, null, null); + }, + initNativeDispatch() { + if (true === $.initNativeDispatchFlag) + return; + $.initNativeDispatchFlag = true; + A.initNativeDispatchContinue(); + }, + initNativeDispatchContinue() { + var map, tags, fun, i, tag, proto, record, interceptorClass; + $.dispatchRecordsForInstanceTags = Object.create(null); + $.interceptorsForUncacheableTags = Object.create(null); + A.initHooks(); + map = init.interceptorsByTag; + tags = Object.getOwnPropertyNames(map); + if (typeof window != "undefined") { + window; + fun = function() { + }; + for (i = 0; i < tags.length; ++i) { + tag = tags[i]; + proto = $.prototypeForTagFunction.call$1(tag); + if (proto != null) { + record = A.makeDefaultDispatchRecord(tag, map[tag], proto); + if (record != null) { + Object.defineProperty(proto, init.dispatchPropertyName, {value: record, enumerable: false, writable: true, configurable: true}); + fun.prototype = proto; + } + } + } + } + for (i = 0; i < tags.length; ++i) { + tag = tags[i]; + if (/^[A-Za-z_]/.test(tag)) { + interceptorClass = map[tag]; + map["!" + tag] = interceptorClass; + map["~" + tag] = interceptorClass; + map["-" + tag] = interceptorClass; + map["+" + tag] = interceptorClass; + map["*" + tag] = interceptorClass; + } + } + }, + initHooks() { + var transformers, i, transformer, getTag, getUnknownTag, prototypeForTag, + hooks = B.C_JS_CONST0(); + hooks = A.applyHooksTransformer(B.C_JS_CONST1, A.applyHooksTransformer(B.C_JS_CONST2, A.applyHooksTransformer(B.C_JS_CONST3, A.applyHooksTransformer(B.C_JS_CONST3, A.applyHooksTransformer(B.C_JS_CONST4, A.applyHooksTransformer(B.C_JS_CONST5, A.applyHooksTransformer(B.C_JS_CONST6(B.C_JS_CONST), hooks))))))); + if (typeof dartNativeDispatchHooksTransformer != "undefined") { + transformers = dartNativeDispatchHooksTransformer; + if (typeof transformers == "function") + transformers = [transformers]; + if (Array.isArray(transformers)) + for (i = 0; i < transformers.length; ++i) { + transformer = transformers[i]; + if (typeof transformer == "function") + hooks = transformer(hooks) || hooks; + } + } + getTag = hooks.getTag; + getUnknownTag = hooks.getUnknownTag; + prototypeForTag = hooks.prototypeForTag; + $.getTagFunction = new A.initHooks_closure(getTag); + $.alternateTagFunction = new A.initHooks_closure0(getUnknownTag); + $.prototypeForTagFunction = new A.initHooks_closure1(prototypeForTag); + }, + applyHooksTransformer(transformer, hooks) { + return transformer(hooks) || hooks; + }, + createRecordTypePredicate(shape, fieldRtis) { + var $length = fieldRtis.length, + $function = init.rttc["" + $length + ";" + shape]; + if ($function == null) + return null; + if ($length === 0) + return $function; + if ($length === $function.length) + return $function.apply(null, fieldRtis); + return $function(fieldRtis); + }, + quoteStringForRegExp(string) { + if (/[[\]{}()*+?.\\^$|]/.test(string)) + return string.replace(/[[\]{}()*+?.\\^$|]/g, "\\$&"); + return string; + }, + ConstantMapView: function ConstantMapView(t0, t1) { + this._collection$_map = t0; + this.$ti = t1; + }, + ConstantMap: function ConstantMap() { + }, + ConstantStringMap: function ConstantStringMap(t0, t1, t2) { + this._jsIndex = t0; + this._values = t1; + this.$ti = t2; + }, + JSInvocationMirror: function JSInvocationMirror(t0, t1, t2, t3, t4) { + var _ = this; + _._memberName = t0; + _.__js_helper$_kind = t1; + _._arguments = t2; + _._namedArgumentNames = t3; + _._typeArgumentCount = t4; + }, + Primitives_functionNoSuchMethod_closure: function Primitives_functionNoSuchMethod_closure(t0, t1, t2) { + this._box_0 = t0; + this.namedArgumentList = t1; + this.$arguments = t2; + }, + TypeErrorDecoder: function TypeErrorDecoder(t0, t1, t2, t3, t4, t5) { + var _ = this; + _._pattern = t0; + _._arguments = t1; + _._argumentsExpr = t2; + _._expr = t3; + _._method = t4; + _._receiver = t5; + }, + NullError: function NullError() { + }, + JsNoSuchMethodError: function JsNoSuchMethodError(t0, t1, t2) { + this.__js_helper$_message = t0; + this._method = t1; + this._receiver = t2; + }, + UnknownJsTypeError: function UnknownJsTypeError(t0) { + this.__js_helper$_message = t0; + }, + NullThrownFromJavaScriptException: function NullThrownFromJavaScriptException(t0) { + this._irritant = t0; + }, + ExceptionAndStackTrace: function ExceptionAndStackTrace(t0, t1) { + this.dartException = t0; + this.stackTrace = t1; + }, + _StackTrace: function _StackTrace(t0) { + this._exception = t0; + this._trace = null; + }, + Closure: function Closure() { + }, + Closure0Args: function Closure0Args() { + }, + Closure2Args: function Closure2Args() { + }, + TearOffClosure: function TearOffClosure() { + }, + StaticClosure: function StaticClosure() { + }, + BoundClosure: function BoundClosure(t0, t1) { + this._receiver = t0; + this._interceptor = t1; + }, + _CyclicInitializationError: function _CyclicInitializationError(t0) { + this.variableName = t0; + }, + RuntimeError: function RuntimeError(t0) { + this.message = t0; + }, + _Required: function _Required() { + }, + JsLinkedHashMap: function JsLinkedHashMap(t0) { + var _ = this; + _.__js_helper$_length = 0; + _._last = _._first = _.__js_helper$_rest = _._nums = _._strings = null; + _._modifications = 0; + _.$ti = t0; + }, + LinkedHashMapCell: function LinkedHashMapCell(t0, t1) { + this.hashMapCellKey = t0; + this.hashMapCellValue = t1; + this._next = null; + }, + LinkedHashMapKeyIterable: function LinkedHashMapKeyIterable(t0, t1) { + this._map = t0; + this.$ti = t1; + }, + LinkedHashMapKeyIterator: function LinkedHashMapKeyIterator(t0, t1, t2) { + var _ = this; + _._map = t0; + _._modifications = t1; + _.__js_helper$_current = _._cell = null; + _.$ti = t2; + }, + initHooks_closure: function initHooks_closure(t0) { + this.getTag = t0; + }, + initHooks_closure0: function initHooks_closure0(t0) { + this.getUnknownTag = t0; + }, + initHooks_closure1: function initHooks_closure1(t0) { + this.prototypeForTag = t0; + }, + StringMatch: function StringMatch(t0, t1) { + this.start = t0; + this.pattern = t1; + }, + _checkValidIndex(index, list, $length) { + if (index >>> 0 !== index || index >= $length) + throw A.wrapException(A.diagnoseIndexError(list, index)); + }, + NativeByteBuffer: function NativeByteBuffer() { + }, + NativeTypedData: function NativeTypedData() { + }, + NativeByteData: function NativeByteData() { + }, + NativeTypedArray: function NativeTypedArray() { + }, + NativeTypedArrayOfDouble: function NativeTypedArrayOfDouble() { + }, + NativeTypedArrayOfInt: function NativeTypedArrayOfInt() { + }, + NativeFloat32List: function NativeFloat32List() { + }, + NativeFloat64List: function NativeFloat64List() { + }, + NativeInt16List: function NativeInt16List() { + }, + NativeInt32List: function NativeInt32List() { + }, + NativeInt8List: function NativeInt8List() { + }, + NativeUint16List: function NativeUint16List() { + }, + NativeUint32List: function NativeUint32List() { + }, + NativeUint8ClampedList: function NativeUint8ClampedList() { + }, + NativeUint8List: function NativeUint8List() { + }, + _NativeTypedArrayOfDouble_NativeTypedArray_ListMixin: function _NativeTypedArrayOfDouble_NativeTypedArray_ListMixin() { + }, + _NativeTypedArrayOfDouble_NativeTypedArray_ListMixin_FixedLengthListMixin: function _NativeTypedArrayOfDouble_NativeTypedArray_ListMixin_FixedLengthListMixin() { + }, + _NativeTypedArrayOfInt_NativeTypedArray_ListMixin: function _NativeTypedArrayOfInt_NativeTypedArray_ListMixin() { + }, + _NativeTypedArrayOfInt_NativeTypedArray_ListMixin_FixedLengthListMixin: function _NativeTypedArrayOfInt_NativeTypedArray_ListMixin_FixedLengthListMixin() { + }, + Rti__getQuestionFromStar(universe, rti) { + var question = rti._precomputed1; + return question == null ? rti._precomputed1 = A._Universe__lookupQuestionRti(universe, rti._primary, true) : question; + }, + Rti__getFutureFromFutureOr(universe, rti) { + var future = rti._precomputed1; + return future == null ? rti._precomputed1 = A._Universe__lookupInterfaceRti(universe, "Future", [rti._primary]) : future; + }, + Rti__isUnionOfFunctionType(rti) { + var kind = rti._kind; + if (kind === 6 || kind === 7 || kind === 8) + return A.Rti__isUnionOfFunctionType(rti._primary); + return kind === 12 || kind === 13; + }, + Rti__getCanonicalRecipe(rti) { + return rti._canonicalRecipe; + }, + findType(recipe) { + return A._Universe_eval(init.typeUniverse, recipe, false); + }, + _substitute(universe, rti, typeArguments, depth) { + var baseType, substitutedBaseType, interfaceTypeArguments, substitutedInterfaceTypeArguments, base, substitutedBase, $arguments, substitutedArguments, t1, fields, substitutedFields, returnType, substitutedReturnType, functionParameters, substitutedFunctionParameters, bounds, substitutedBounds, index, argument, + kind = rti._kind; + switch (kind) { + case 5: + case 1: + case 2: + case 3: + case 4: + return rti; + case 6: + baseType = rti._primary; + substitutedBaseType = A._substitute(universe, baseType, typeArguments, depth); + if (substitutedBaseType === baseType) + return rti; + return A._Universe__lookupStarRti(universe, substitutedBaseType, true); + case 7: + baseType = rti._primary; + substitutedBaseType = A._substitute(universe, baseType, typeArguments, depth); + if (substitutedBaseType === baseType) + return rti; + return A._Universe__lookupQuestionRti(universe, substitutedBaseType, true); + case 8: + baseType = rti._primary; + substitutedBaseType = A._substitute(universe, baseType, typeArguments, depth); + if (substitutedBaseType === baseType) + return rti; + return A._Universe__lookupFutureOrRti(universe, substitutedBaseType, true); + case 9: + interfaceTypeArguments = rti._rest; + substitutedInterfaceTypeArguments = A._substituteArray(universe, interfaceTypeArguments, typeArguments, depth); + if (substitutedInterfaceTypeArguments === interfaceTypeArguments) + return rti; + return A._Universe__lookupInterfaceRti(universe, rti._primary, substitutedInterfaceTypeArguments); + case 10: + base = rti._primary; + substitutedBase = A._substitute(universe, base, typeArguments, depth); + $arguments = rti._rest; + substitutedArguments = A._substituteArray(universe, $arguments, typeArguments, depth); + if (substitutedBase === base && substitutedArguments === $arguments) + return rti; + return A._Universe__lookupBindingRti(universe, substitutedBase, substitutedArguments); + case 11: + t1 = rti._primary; + fields = rti._rest; + substitutedFields = A._substituteArray(universe, fields, typeArguments, depth); + if (substitutedFields === fields) + return rti; + return A._Universe__lookupRecordRti(universe, t1, substitutedFields); + case 12: + returnType = rti._primary; + substitutedReturnType = A._substitute(universe, returnType, typeArguments, depth); + functionParameters = rti._rest; + substitutedFunctionParameters = A._substituteFunctionParameters(universe, functionParameters, typeArguments, depth); + if (substitutedReturnType === returnType && substitutedFunctionParameters === functionParameters) + return rti; + return A._Universe__lookupFunctionRti(universe, substitutedReturnType, substitutedFunctionParameters); + case 13: + bounds = rti._rest; + depth += bounds.length; + substitutedBounds = A._substituteArray(universe, bounds, typeArguments, depth); + base = rti._primary; + substitutedBase = A._substitute(universe, base, typeArguments, depth); + if (substitutedBounds === bounds && substitutedBase === base) + return rti; + return A._Universe__lookupGenericFunctionRti(universe, substitutedBase, substitutedBounds, true); + case 14: + index = rti._primary; + if (index < depth) + return rti; + argument = typeArguments[index - depth]; + if (argument == null) + return rti; + return argument; + default: + throw A.wrapException(A.AssertionError$("Attempted to substitute unexpected RTI kind " + kind)); + } + }, + _substituteArray(universe, rtiArray, typeArguments, depth) { + var changed, i, rti, substitutedRti, + $length = rtiArray.length, + result = A._Utils_newArrayOrEmpty($length); + for (changed = false, i = 0; i < $length; ++i) { + rti = rtiArray[i]; + substitutedRti = A._substitute(universe, rti, typeArguments, depth); + if (substitutedRti !== rti) + changed = true; + result[i] = substitutedRti; + } + return changed ? result : rtiArray; + }, + _substituteNamed(universe, namedArray, typeArguments, depth) { + var changed, i, t1, t2, rti, substitutedRti, + $length = namedArray.length, + result = A._Utils_newArrayOrEmpty($length); + for (changed = false, i = 0; i < $length; i += 3) { + t1 = namedArray[i]; + t2 = namedArray[i + 1]; + rti = namedArray[i + 2]; + substitutedRti = A._substitute(universe, rti, typeArguments, depth); + if (substitutedRti !== rti) + changed = true; + result.splice(i, 3, t1, t2, substitutedRti); + } + return changed ? result : namedArray; + }, + _substituteFunctionParameters(universe, functionParameters, typeArguments, depth) { + var result, + requiredPositional = functionParameters._requiredPositional, + substitutedRequiredPositional = A._substituteArray(universe, requiredPositional, typeArguments, depth), + optionalPositional = functionParameters._optionalPositional, + substitutedOptionalPositional = A._substituteArray(universe, optionalPositional, typeArguments, depth), + named = functionParameters._named, + substitutedNamed = A._substituteNamed(universe, named, typeArguments, depth); + if (substitutedRequiredPositional === requiredPositional && substitutedOptionalPositional === optionalPositional && substitutedNamed === named) + return functionParameters; + result = new A._FunctionParameters(); + result._requiredPositional = substitutedRequiredPositional; + result._optionalPositional = substitutedOptionalPositional; + result._named = substitutedNamed; + return result; + }, + _setArrayType(target, rti) { + target[init.arrayRti] = rti; + return target; + }, + closureFunctionType(closure) { + var signature = closure.$signature; + if (signature != null) { + if (typeof signature == "number") + return A.getTypeFromTypesTable(signature); + return closure.$signature(); + } + return null; + }, + instanceOrFunctionType(object, testRti) { + var rti; + if (A.Rti__isUnionOfFunctionType(testRti)) + if (object instanceof A.Closure) { + rti = A.closureFunctionType(object); + if (rti != null) + return rti; + } + return A.instanceType(object); + }, + instanceType(object) { + if (object instanceof A.Object) + return A._instanceType(object); + if (Array.isArray(object)) + return A._arrayInstanceType(object); + return A._instanceTypeFromConstructor(J.getInterceptor$(object)); + }, + _arrayInstanceType(object) { + var rti = object[init.arrayRti], + defaultRti = type$.JSArray_dynamic; + if (rti == null) + return defaultRti; + if (rti.constructor !== defaultRti.constructor) + return defaultRti; + return rti; + }, + _instanceType(object) { + var rti = object.$ti; + return rti != null ? rti : A._instanceTypeFromConstructor(object); + }, + _instanceTypeFromConstructor(instance) { + var $constructor = instance.constructor, + probe = $constructor.$ccache; + if (probe != null) + return probe; + return A._instanceTypeFromConstructorMiss(instance, $constructor); + }, + _instanceTypeFromConstructorMiss(instance, $constructor) { + var effectiveConstructor = instance instanceof A.Closure ? Object.getPrototypeOf(Object.getPrototypeOf(instance)).constructor : $constructor, + rti = A._Universe_findErasedType(init.typeUniverse, effectiveConstructor.name); + $constructor.$ccache = rti; + return rti; + }, + getTypeFromTypesTable(index) { + var rti, + table = init.types, + type = table[index]; + if (typeof type == "string") { + rti = A._Universe_eval(init.typeUniverse, type, false); + table[index] = rti; + return rti; + } + return type; + }, + getRuntimeTypeOfDartObject(object) { + return A.createRuntimeType(A._instanceType(object)); + }, + _structuralTypeOf(object) { + var functionRti = object instanceof A.Closure ? A.closureFunctionType(object) : null; + if (functionRti != null) + return functionRti; + if (type$.TrustedGetRuntimeType._is(object)) + return J.get$runtimeType$(object)._rti; + if (Array.isArray(object)) + return A._arrayInstanceType(object); + return A.instanceType(object); + }, + createRuntimeType(rti) { + var t1 = rti._cachedRuntimeType; + return t1 == null ? rti._cachedRuntimeType = A._createRuntimeType(rti) : t1; + }, + _createRuntimeType(rti) { + var starErasedRti, t1, + s = rti._canonicalRecipe, + starErasedRecipe = s.replace(/\*/g, ""); + if (starErasedRecipe === s) + return rti._cachedRuntimeType = new A._Type(rti); + starErasedRti = A._Universe_eval(init.typeUniverse, starErasedRecipe, true); + t1 = starErasedRti._cachedRuntimeType; + return t1 == null ? starErasedRti._cachedRuntimeType = A._createRuntimeType(starErasedRti) : t1; + }, + typeLiteral(recipe) { + return A.createRuntimeType(A._Universe_eval(init.typeUniverse, recipe, false)); + }, + _installSpecializedIsTest(object) { + var t1, unstarred, unstarredKind, isFn, $name, predicate, testRti = this; + if (testRti === type$.Object) + return A._finishIsFn(testRti, object, A._isObject); + if (!A.isSoundTopType(testRti)) + t1 = testRti === type$.legacy_Object; + else + t1 = true; + if (t1) + return A._finishIsFn(testRti, object, A._isTop); + t1 = testRti._kind; + if (t1 === 7) + return A._finishIsFn(testRti, object, A._generalNullableIsTestImplementation); + if (t1 === 1) + return A._finishIsFn(testRti, object, A._isNever); + unstarred = t1 === 6 ? testRti._primary : testRti; + unstarredKind = unstarred._kind; + if (unstarredKind === 8) + return A._finishIsFn(testRti, object, A._isFutureOr); + if (unstarred === type$.int) + isFn = A._isInt; + else if (unstarred === type$.double || unstarred === type$.num) + isFn = A._isNum; + else if (unstarred === type$.String) + isFn = A._isString; + else + isFn = unstarred === type$.bool ? A._isBool : null; + if (isFn != null) + return A._finishIsFn(testRti, object, isFn); + if (unstarredKind === 9) { + $name = unstarred._primary; + if (unstarred._rest.every(A.isDefinitelyTopType)) { + testRti._specializedTestResource = "$is" + $name; + if ($name === "List") + return A._finishIsFn(testRti, object, A._isListTestViaProperty); + return A._finishIsFn(testRti, object, A._isTestViaProperty); + } + } else if (unstarredKind === 11) { + predicate = A.createRecordTypePredicate(unstarred._primary, unstarred._rest); + return A._finishIsFn(testRti, object, predicate == null ? A._isNever : predicate); + } + return A._finishIsFn(testRti, object, A._generalIsTestImplementation); + }, + _finishIsFn(testRti, object, isFn) { + testRti._is = isFn; + return testRti._is(object); + }, + _installSpecializedAsCheck(object) { + var t1, testRti = this, + asFn = A._generalAsCheckImplementation; + if (!A.isSoundTopType(testRti)) + t1 = testRti === type$.legacy_Object; + else + t1 = true; + if (t1) + asFn = A._asTop; + else if (testRti === type$.Object) + asFn = A._asObject; + else { + t1 = A.isNullable(testRti); + if (t1) + asFn = A._generalNullableAsCheckImplementation; + } + testRti._as = asFn; + return testRti._as(object); + }, + _nullIs(testRti) { + var t1, + kind = testRti._kind; + if (!A.isSoundTopType(testRti)) + if (!(testRti === type$.legacy_Object)) + if (!(testRti === type$.legacy_Never)) + if (kind !== 7) + if (!(kind === 6 && A._nullIs(testRti._primary))) + t1 = kind === 8 && A._nullIs(testRti._primary) || testRti === type$.Null || testRti === type$.JSNull; + else + t1 = true; + else + t1 = true; + else + t1 = true; + else + t1 = true; + else + t1 = true; + return t1; + }, + _generalIsTestImplementation(object) { + var testRti = this; + if (object == null) + return A._nullIs(testRti); + return A.isSubtype(init.typeUniverse, A.instanceOrFunctionType(object, testRti), testRti); + }, + _generalNullableIsTestImplementation(object) { + if (object == null) + return true; + return this._primary._is(object); + }, + _isTestViaProperty(object) { + var tag, testRti = this; + if (object == null) + return A._nullIs(testRti); + tag = testRti._specializedTestResource; + if (object instanceof A.Object) + return !!object[tag]; + return !!J.getInterceptor$(object)[tag]; + }, + _isListTestViaProperty(object) { + var tag, testRti = this; + if (object == null) + return A._nullIs(testRti); + if (typeof object != "object") + return false; + if (Array.isArray(object)) + return true; + tag = testRti._specializedTestResource; + if (object instanceof A.Object) + return !!object[tag]; + return !!J.getInterceptor$(object)[tag]; + }, + _generalAsCheckImplementation(object) { + var testRti = this; + if (object == null) { + if (A.isNullable(testRti)) + return object; + } else if (testRti._is(object)) + return object; + A._failedAsCheck(object, testRti); + }, + _generalNullableAsCheckImplementation(object) { + var testRti = this; + if (object == null) + return object; + else if (testRti._is(object)) + return object; + A._failedAsCheck(object, testRti); + }, + _failedAsCheck(object, testRti) { + throw A.wrapException(A._TypeError$fromMessage(A._Error_compose(object, A._rtiToString(testRti, null)))); + }, + _Error_compose(object, checkedTypeDescription) { + return A.Error_safeToString(object) + ": type '" + A._rtiToString(A._structuralTypeOf(object), null) + "' is not a subtype of type '" + checkedTypeDescription + "'"; + }, + _TypeError$fromMessage(message) { + return new A._TypeError("TypeError: " + message); + }, + _TypeError__TypeError$forType(object, type) { + return new A._TypeError("TypeError: " + A._Error_compose(object, type)); + }, + _isFutureOr(object) { + var testRti = this, + unstarred = testRti._kind === 6 ? testRti._primary : testRti; + return unstarred._primary._is(object) || A.Rti__getFutureFromFutureOr(init.typeUniverse, unstarred)._is(object); + }, + _isObject(object) { + return object != null; + }, + _asObject(object) { + if (object != null) + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "Object")); + }, + _isTop(object) { + return true; + }, + _asTop(object) { + return object; + }, + _isNever(object) { + return false; + }, + _isBool(object) { + return true === object || false === object; + }, + _asBool(object) { + if (true === object) + return true; + if (false === object) + return false; + throw A.wrapException(A._TypeError__TypeError$forType(object, "bool")); + }, + _asBoolS(object) { + if (true === object) + return true; + if (false === object) + return false; + if (object == null) + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "bool")); + }, + _asBoolQ(object) { + if (true === object) + return true; + if (false === object) + return false; + if (object == null) + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "bool?")); + }, + _asDouble(object) { + if (typeof object == "number") + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "double")); + }, + _asDoubleS(object) { + if (typeof object == "number") + return object; + if (object == null) + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "double")); + }, + _asDoubleQ(object) { + if (typeof object == "number") + return object; + if (object == null) + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "double?")); + }, + _isInt(object) { + return typeof object == "number" && Math.floor(object) === object; + }, + _asInt(object) { + if (typeof object == "number" && Math.floor(object) === object) + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "int")); + }, + _asIntS(object) { + if (typeof object == "number" && Math.floor(object) === object) + return object; + if (object == null) + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "int")); + }, + _asIntQ(object) { + if (typeof object == "number" && Math.floor(object) === object) + return object; + if (object == null) + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "int?")); + }, + _isNum(object) { + return typeof object == "number"; + }, + _asNum(object) { + if (typeof object == "number") + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "num")); + }, + _asNumS(object) { + if (typeof object == "number") + return object; + if (object == null) + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "num")); + }, + _asNumQ(object) { + if (typeof object == "number") + return object; + if (object == null) + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "num?")); + }, + _isString(object) { + return typeof object == "string"; + }, + _asString(object) { + if (typeof object == "string") + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "String")); + }, + _asStringS(object) { + if (typeof object == "string") + return object; + if (object == null) + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "String")); + }, + _asStringQ(object) { + if (typeof object == "string") + return object; + if (object == null) + return object; + throw A.wrapException(A._TypeError__TypeError$forType(object, "String?")); + }, + _rtiArrayToString(array, genericContext) { + var s, sep, i; + for (s = "", sep = "", i = 0; i < array.length; ++i, sep = ", ") + s += sep + A._rtiToString(array[i], genericContext); + return s; + }, + _recordRtiToString(recordType, genericContext) { + var fieldCount, names, namesIndex, s, comma, i, + partialShape = recordType._primary, + fields = recordType._rest; + if ("" === partialShape) + return "(" + A._rtiArrayToString(fields, genericContext) + ")"; + fieldCount = fields.length; + names = partialShape.split(","); + namesIndex = names.length - fieldCount; + for (s = "(", comma = "", i = 0; i < fieldCount; ++i, comma = ", ") { + s += comma; + if (namesIndex === 0) + s += "{"; + s += A._rtiToString(fields[i], genericContext); + if (namesIndex >= 0) + s += " " + names[namesIndex]; + ++namesIndex; + } + return s + "})"; + }, + _functionRtiToString(functionType, genericContext, bounds) { + var boundsLength, outerContextLength, offset, i, t1, t2, typeParametersText, typeSep, t3, t4, boundRti, kind, parameters, requiredPositional, requiredPositionalLength, optionalPositional, optionalPositionalLength, named, namedLength, returnTypeText, argumentsText, sep, _s2_ = ", "; + if (bounds != null) { + boundsLength = bounds.length; + if (genericContext == null) { + genericContext = A._setArrayType([], type$.JSArray_String); + outerContextLength = null; + } else + outerContextLength = genericContext.length; + offset = genericContext.length; + for (i = boundsLength; i > 0; --i) + B.JSArray_methods.add$1(genericContext, "T" + (offset + i)); + for (t1 = type$.nullable_Object, t2 = type$.legacy_Object, typeParametersText = "<", typeSep = "", i = 0; i < boundsLength; ++i, typeSep = _s2_) { + t3 = genericContext.length; + t4 = t3 - 1 - i; + if (!(t4 >= 0)) + return A.ioore(genericContext, t4); + typeParametersText = B.JSString_methods.$add(typeParametersText + typeSep, genericContext[t4]); + boundRti = bounds[i]; + kind = boundRti._kind; + if (!(kind === 2 || kind === 3 || kind === 4 || kind === 5 || boundRti === t1)) + t3 = boundRti === t2; + else + t3 = true; + if (!t3) + typeParametersText += " extends " + A._rtiToString(boundRti, genericContext); + } + typeParametersText += ">"; + } else { + typeParametersText = ""; + outerContextLength = null; + } + t1 = functionType._primary; + parameters = functionType._rest; + requiredPositional = parameters._requiredPositional; + requiredPositionalLength = requiredPositional.length; + optionalPositional = parameters._optionalPositional; + optionalPositionalLength = optionalPositional.length; + named = parameters._named; + namedLength = named.length; + returnTypeText = A._rtiToString(t1, genericContext); + for (argumentsText = "", sep = "", i = 0; i < requiredPositionalLength; ++i, sep = _s2_) + argumentsText += sep + A._rtiToString(requiredPositional[i], genericContext); + if (optionalPositionalLength > 0) { + argumentsText += sep + "["; + for (sep = "", i = 0; i < optionalPositionalLength; ++i, sep = _s2_) + argumentsText += sep + A._rtiToString(optionalPositional[i], genericContext); + argumentsText += "]"; + } + if (namedLength > 0) { + argumentsText += sep + "{"; + for (sep = "", i = 0; i < namedLength; i += 3, sep = _s2_) { + argumentsText += sep; + if (named[i + 1]) + argumentsText += "required "; + argumentsText += A._rtiToString(named[i + 2], genericContext) + " " + named[i]; + } + argumentsText += "}"; + } + if (outerContextLength != null) { + genericContext.toString; + genericContext.length = outerContextLength; + } + return typeParametersText + "(" + argumentsText + ") => " + returnTypeText; + }, + _rtiToString(rti, genericContext) { + var questionArgument, s, argumentKind, $name, $arguments, t1, t2, + kind = rti._kind; + if (kind === 5) + return "erased"; + if (kind === 2) + return "dynamic"; + if (kind === 3) + return "void"; + if (kind === 1) + return "Never"; + if (kind === 4) + return "any"; + if (kind === 6) + return A._rtiToString(rti._primary, genericContext); + if (kind === 7) { + questionArgument = rti._primary; + s = A._rtiToString(questionArgument, genericContext); + argumentKind = questionArgument._kind; + return (argumentKind === 12 || argumentKind === 13 ? "(" + s + ")" : s) + "?"; + } + if (kind === 8) + return "FutureOr<" + A._rtiToString(rti._primary, genericContext) + ">"; + if (kind === 9) { + $name = A._unminifyOrTag(rti._primary); + $arguments = rti._rest; + return $arguments.length > 0 ? $name + ("<" + A._rtiArrayToString($arguments, genericContext) + ">") : $name; + } + if (kind === 11) + return A._recordRtiToString(rti, genericContext); + if (kind === 12) + return A._functionRtiToString(rti, genericContext, null); + if (kind === 13) + return A._functionRtiToString(rti._primary, genericContext, rti._rest); + if (kind === 14) { + t1 = rti._primary; + t2 = genericContext.length; + t1 = t2 - 1 - t1; + if (!(t1 >= 0 && t1 < t2)) + return A.ioore(genericContext, t1); + return genericContext[t1]; + } + return "?"; + }, + _unminifyOrTag(rawClassName) { + var preserved = init.mangledGlobalNames[rawClassName]; + if (preserved != null) + return preserved; + return rawClassName; + }, + _Universe_findRule(universe, targetType) { + var rule = universe.tR[targetType]; + for (; typeof rule == "string";) + rule = universe.tR[rule]; + return rule; + }, + _Universe_findErasedType(universe, cls) { + var $length, erased, $arguments, i, $interface, + t1 = universe.eT, + probe = t1[cls]; + if (probe == null) + return A._Universe_eval(universe, cls, false); + else if (typeof probe == "number") { + $length = probe; + erased = A._Universe__lookupTerminalRti(universe, 5, "#"); + $arguments = A._Utils_newArrayOrEmpty($length); + for (i = 0; i < $length; ++i) + $arguments[i] = erased; + $interface = A._Universe__lookupInterfaceRti(universe, cls, $arguments); + t1[cls] = $interface; + return $interface; + } else + return probe; + }, + _Universe_addRules(universe, rules) { + return A._Utils_objectAssign(universe.tR, rules); + }, + _Universe_addErasedTypes(universe, types) { + return A._Utils_objectAssign(universe.eT, types); + }, + _Universe_eval(universe, recipe, normalize) { + var rti, + t1 = universe.eC, + probe = t1.get(recipe); + if (probe != null) + return probe; + rti = A._Parser_parse(A._Parser_create(universe, null, recipe, normalize)); + t1.set(recipe, rti); + return rti; + }, + _Universe_evalInEnvironment(universe, environment, recipe) { + var probe, rti, + cache = environment._evalCache; + if (cache == null) + cache = environment._evalCache = new Map(); + probe = cache.get(recipe); + if (probe != null) + return probe; + rti = A._Parser_parse(A._Parser_create(universe, environment, recipe, true)); + cache.set(recipe, rti); + return rti; + }, + _Universe_bind(universe, environment, argumentsRti) { + var argumentsRecipe, probe, rti, + cache = environment._bindCache; + if (cache == null) + cache = environment._bindCache = new Map(); + argumentsRecipe = argumentsRti._canonicalRecipe; + probe = cache.get(argumentsRecipe); + if (probe != null) + return probe; + rti = A._Universe__lookupBindingRti(universe, environment, argumentsRti._kind === 10 ? argumentsRti._rest : [argumentsRti]); + cache.set(argumentsRecipe, rti); + return rti; + }, + _Universe__installTypeTests(universe, rti) { + rti._as = A._installSpecializedAsCheck; + rti._is = A._installSpecializedIsTest; + return rti; + }, + _Universe__lookupTerminalRti(universe, kind, key) { + var rti, t1, + probe = universe.eC.get(key); + if (probe != null) + return probe; + rti = new A.Rti(null, null); + rti._kind = kind; + rti._canonicalRecipe = key; + t1 = A._Universe__installTypeTests(universe, rti); + universe.eC.set(key, t1); + return t1; + }, + _Universe__lookupStarRti(universe, baseType, normalize) { + var t1, + key = baseType._canonicalRecipe + "*", + probe = universe.eC.get(key); + if (probe != null) + return probe; + t1 = A._Universe__createStarRti(universe, baseType, key, normalize); + universe.eC.set(key, t1); + return t1; + }, + _Universe__createStarRti(universe, baseType, key, normalize) { + var baseKind, t1, rti; + if (normalize) { + baseKind = baseType._kind; + if (!A.isSoundTopType(baseType)) + t1 = baseType === type$.Null || baseType === type$.JSNull || baseKind === 7 || baseKind === 6; + else + t1 = true; + if (t1) + return baseType; + } + rti = new A.Rti(null, null); + rti._kind = 6; + rti._primary = baseType; + rti._canonicalRecipe = key; + return A._Universe__installTypeTests(universe, rti); + }, + _Universe__lookupQuestionRti(universe, baseType, normalize) { + var t1, + key = baseType._canonicalRecipe + "?", + probe = universe.eC.get(key); + if (probe != null) + return probe; + t1 = A._Universe__createQuestionRti(universe, baseType, key, normalize); + universe.eC.set(key, t1); + return t1; + }, + _Universe__createQuestionRti(universe, baseType, key, normalize) { + var baseKind, t1, starArgument, rti; + if (normalize) { + baseKind = baseType._kind; + if (!A.isSoundTopType(baseType)) + if (!(baseType === type$.Null || baseType === type$.JSNull)) + if (baseKind !== 7) + t1 = baseKind === 8 && A.isNullable(baseType._primary); + else + t1 = true; + else + t1 = true; + else + t1 = true; + if (t1) + return baseType; + else if (baseKind === 1 || baseType === type$.legacy_Never) + return type$.Null; + else if (baseKind === 6) { + starArgument = baseType._primary; + if (starArgument._kind === 8 && A.isNullable(starArgument._primary)) + return starArgument; + else + return A.Rti__getQuestionFromStar(universe, baseType); + } + } + rti = new A.Rti(null, null); + rti._kind = 7; + rti._primary = baseType; + rti._canonicalRecipe = key; + return A._Universe__installTypeTests(universe, rti); + }, + _Universe__lookupFutureOrRti(universe, baseType, normalize) { + var t1, + key = baseType._canonicalRecipe + "/", + probe = universe.eC.get(key); + if (probe != null) + return probe; + t1 = A._Universe__createFutureOrRti(universe, baseType, key, normalize); + universe.eC.set(key, t1); + return t1; + }, + _Universe__createFutureOrRti(universe, baseType, key, normalize) { + var t1, rti; + if (normalize) { + t1 = baseType._kind; + if (A.isSoundTopType(baseType) || baseType === type$.Object || baseType === type$.legacy_Object) + return baseType; + else if (t1 === 1) + return A._Universe__lookupInterfaceRti(universe, "Future", [baseType]); + else if (baseType === type$.Null || baseType === type$.JSNull) + return type$.nullable_Future_Null; + } + rti = new A.Rti(null, null); + rti._kind = 8; + rti._primary = baseType; + rti._canonicalRecipe = key; + return A._Universe__installTypeTests(universe, rti); + }, + _Universe__lookupGenericFunctionParameterRti(universe, index) { + var rti, t1, + key = "" + index + "^", + probe = universe.eC.get(key); + if (probe != null) + return probe; + rti = new A.Rti(null, null); + rti._kind = 14; + rti._primary = index; + rti._canonicalRecipe = key; + t1 = A._Universe__installTypeTests(universe, rti); + universe.eC.set(key, t1); + return t1; + }, + _Universe__canonicalRecipeJoin($arguments) { + var s, sep, i, + $length = $arguments.length; + for (s = "", sep = "", i = 0; i < $length; ++i, sep = ",") + s += sep + $arguments[i]._canonicalRecipe; + return s; + }, + _Universe__canonicalRecipeJoinNamed($arguments) { + var s, sep, i, t1, nameSep, + $length = $arguments.length; + for (s = "", sep = "", i = 0; i < $length; i += 3, sep = ",") { + t1 = $arguments[i]; + nameSep = $arguments[i + 1] ? "!" : ":"; + s += sep + t1 + nameSep + $arguments[i + 2]._canonicalRecipe; + } + return s; + }, + _Universe__lookupInterfaceRti(universe, $name, $arguments) { + var probe, rti, t1, + s = $name; + if ($arguments.length > 0) + s += "<" + A._Universe__canonicalRecipeJoin($arguments) + ">"; + probe = universe.eC.get(s); + if (probe != null) + return probe; + rti = new A.Rti(null, null); + rti._kind = 9; + rti._primary = $name; + rti._rest = $arguments; + if ($arguments.length > 0) + rti._precomputed1 = $arguments[0]; + rti._canonicalRecipe = s; + t1 = A._Universe__installTypeTests(universe, rti); + universe.eC.set(s, t1); + return t1; + }, + _Universe__lookupBindingRti(universe, base, $arguments) { + var newBase, newArguments, key, probe, rti, t1; + if (base._kind === 10) { + newBase = base._primary; + newArguments = base._rest.concat($arguments); + } else { + newArguments = $arguments; + newBase = base; + } + key = newBase._canonicalRecipe + (";<" + A._Universe__canonicalRecipeJoin(newArguments) + ">"); + probe = universe.eC.get(key); + if (probe != null) + return probe; + rti = new A.Rti(null, null); + rti._kind = 10; + rti._primary = newBase; + rti._rest = newArguments; + rti._canonicalRecipe = key; + t1 = A._Universe__installTypeTests(universe, rti); + universe.eC.set(key, t1); + return t1; + }, + _Universe__lookupRecordRti(universe, partialShapeTag, fields) { + var rti, t1, + key = "+" + (partialShapeTag + "(" + A._Universe__canonicalRecipeJoin(fields) + ")"), + probe = universe.eC.get(key); + if (probe != null) + return probe; + rti = new A.Rti(null, null); + rti._kind = 11; + rti._primary = partialShapeTag; + rti._rest = fields; + rti._canonicalRecipe = key; + t1 = A._Universe__installTypeTests(universe, rti); + universe.eC.set(key, t1); + return t1; + }, + _Universe__lookupFunctionRti(universe, returnType, parameters) { + var sep, key, probe, rti, t1, + s = returnType._canonicalRecipe, + requiredPositional = parameters._requiredPositional, + requiredPositionalLength = requiredPositional.length, + optionalPositional = parameters._optionalPositional, + optionalPositionalLength = optionalPositional.length, + named = parameters._named, + namedLength = named.length, + recipe = "(" + A._Universe__canonicalRecipeJoin(requiredPositional); + if (optionalPositionalLength > 0) { + sep = requiredPositionalLength > 0 ? "," : ""; + recipe += sep + "[" + A._Universe__canonicalRecipeJoin(optionalPositional) + "]"; + } + if (namedLength > 0) { + sep = requiredPositionalLength > 0 ? "," : ""; + recipe += sep + "{" + A._Universe__canonicalRecipeJoinNamed(named) + "}"; + } + key = s + (recipe + ")"); + probe = universe.eC.get(key); + if (probe != null) + return probe; + rti = new A.Rti(null, null); + rti._kind = 12; + rti._primary = returnType; + rti._rest = parameters; + rti._canonicalRecipe = key; + t1 = A._Universe__installTypeTests(universe, rti); + universe.eC.set(key, t1); + return t1; + }, + _Universe__lookupGenericFunctionRti(universe, baseFunctionType, bounds, normalize) { + var t1, + key = baseFunctionType._canonicalRecipe + ("<" + A._Universe__canonicalRecipeJoin(bounds) + ">"), + probe = universe.eC.get(key); + if (probe != null) + return probe; + t1 = A._Universe__createGenericFunctionRti(universe, baseFunctionType, bounds, key, normalize); + universe.eC.set(key, t1); + return t1; + }, + _Universe__createGenericFunctionRti(universe, baseFunctionType, bounds, key, normalize) { + var $length, typeArguments, count, i, bound, substitutedBase, substitutedBounds, rti; + if (normalize) { + $length = bounds.length; + typeArguments = A._Utils_newArrayOrEmpty($length); + for (count = 0, i = 0; i < $length; ++i) { + bound = bounds[i]; + if (bound._kind === 1) { + typeArguments[i] = bound; + ++count; + } + } + if (count > 0) { + substitutedBase = A._substitute(universe, baseFunctionType, typeArguments, 0); + substitutedBounds = A._substituteArray(universe, bounds, typeArguments, 0); + return A._Universe__lookupGenericFunctionRti(universe, substitutedBase, substitutedBounds, bounds !== substitutedBounds); + } + } + rti = new A.Rti(null, null); + rti._kind = 13; + rti._primary = baseFunctionType; + rti._rest = bounds; + rti._canonicalRecipe = key; + return A._Universe__installTypeTests(universe, rti); + }, + _Parser_create(universe, environment, recipe, normalize) { + return {u: universe, e: environment, r: recipe, s: [], p: 0, n: normalize}; + }, + _Parser_parse(parser) { + var t2, i, ch, t3, array, end, item, + source = parser.r, + t1 = parser.s; + for (t2 = source.length, i = 0; i < t2;) { + ch = source.charCodeAt(i); + if (ch >= 48 && ch <= 57) + i = A._Parser_handleDigit(i + 1, ch, source, t1); + else if ((((ch | 32) >>> 0) - 97 & 65535) < 26 || ch === 95 || ch === 36 || ch === 124) + i = A._Parser_handleIdentifier(parser, i, source, t1, false); + else if (ch === 46) + i = A._Parser_handleIdentifier(parser, i, source, t1, true); + else { + ++i; + switch (ch) { + case 44: + break; + case 58: + t1.push(false); + break; + case 33: + t1.push(true); + break; + case 59: + t1.push(A._Parser_toType(parser.u, parser.e, t1.pop())); + break; + case 94: + t1.push(A._Universe__lookupGenericFunctionParameterRti(parser.u, t1.pop())); + break; + case 35: + t1.push(A._Universe__lookupTerminalRti(parser.u, 5, "#")); + break; + case 64: + t1.push(A._Universe__lookupTerminalRti(parser.u, 2, "@")); + break; + case 126: + t1.push(A._Universe__lookupTerminalRti(parser.u, 3, "~")); + break; + case 60: + t1.push(parser.p); + parser.p = t1.length; + break; + case 62: + A._Parser_handleTypeArguments(parser, t1); + break; + case 38: + A._Parser_handleExtendedOperations(parser, t1); + break; + case 42: + t3 = parser.u; + t1.push(A._Universe__lookupStarRti(t3, A._Parser_toType(t3, parser.e, t1.pop()), parser.n)); + break; + case 63: + t3 = parser.u; + t1.push(A._Universe__lookupQuestionRti(t3, A._Parser_toType(t3, parser.e, t1.pop()), parser.n)); + break; + case 47: + t3 = parser.u; + t1.push(A._Universe__lookupFutureOrRti(t3, A._Parser_toType(t3, parser.e, t1.pop()), parser.n)); + break; + case 40: + t1.push(-3); + t1.push(parser.p); + parser.p = t1.length; + break; + case 41: + A._Parser_handleArguments(parser, t1); + break; + case 91: + t1.push(parser.p); + parser.p = t1.length; + break; + case 93: + array = t1.splice(parser.p); + A._Parser_toTypes(parser.u, parser.e, array); + parser.p = t1.pop(); + t1.push(array); + t1.push(-1); + break; + case 123: + t1.push(parser.p); + parser.p = t1.length; + break; + case 125: + array = t1.splice(parser.p); + A._Parser_toTypesNamed(parser.u, parser.e, array); + parser.p = t1.pop(); + t1.push(array); + t1.push(-2); + break; + case 43: + end = source.indexOf("(", i); + t1.push(source.substring(i, end)); + t1.push(-4); + t1.push(parser.p); + parser.p = t1.length; + i = end + 1; + break; + default: + throw "Bad character " + ch; + } + } + } + item = t1.pop(); + return A._Parser_toType(parser.u, parser.e, item); + }, + _Parser_handleDigit(i, digit, source, stack) { + var t1, ch, + value = digit - 48; + for (t1 = source.length; i < t1; ++i) { + ch = source.charCodeAt(i); + if (!(ch >= 48 && ch <= 57)) + break; + value = value * 10 + (ch - 48); + } + stack.push(value); + return i; + }, + _Parser_handleIdentifier(parser, start, source, stack, hasPeriod) { + var t1, ch, t2, string, environment, recipe, + i = start + 1; + for (t1 = source.length; i < t1; ++i) { + ch = source.charCodeAt(i); + if (ch === 46) { + if (hasPeriod) + break; + hasPeriod = true; + } else { + if (!((((ch | 32) >>> 0) - 97 & 65535) < 26 || ch === 95 || ch === 36 || ch === 124)) + t2 = ch >= 48 && ch <= 57; + else + t2 = true; + if (!t2) + break; + } + } + string = source.substring(start, i); + if (hasPeriod) { + t1 = parser.u; + environment = parser.e; + if (environment._kind === 10) + environment = environment._primary; + recipe = A._Universe_findRule(t1, environment._primary)[string]; + if (recipe == null) + A.throwExpression('No "' + string + '" in "' + A.Rti__getCanonicalRecipe(environment) + '"'); + stack.push(A._Universe_evalInEnvironment(t1, environment, recipe)); + } else + stack.push(string); + return i; + }, + _Parser_handleTypeArguments(parser, stack) { + var base, + t1 = parser.u, + $arguments = A._Parser_collectArray(parser, stack), + head = stack.pop(); + if (typeof head == "string") + stack.push(A._Universe__lookupInterfaceRti(t1, head, $arguments)); + else { + base = A._Parser_toType(t1, parser.e, head); + switch (base._kind) { + case 12: + stack.push(A._Universe__lookupGenericFunctionRti(t1, base, $arguments, parser.n)); + break; + default: + stack.push(A._Universe__lookupBindingRti(t1, base, $arguments)); + break; + } + } + }, + _Parser_handleArguments(parser, stack) { + var optionalPositional, named, requiredPositional, returnType, parameters, _null = null, + t1 = parser.u, + head = stack.pop(); + if (typeof head == "number") + switch (head) { + case -1: + optionalPositional = stack.pop(); + named = _null; + break; + case -2: + named = stack.pop(); + optionalPositional = _null; + break; + default: + stack.push(head); + named = _null; + optionalPositional = named; + break; + } + else { + stack.push(head); + named = _null; + optionalPositional = named; + } + requiredPositional = A._Parser_collectArray(parser, stack); + head = stack.pop(); + switch (head) { + case -3: + head = stack.pop(); + if (optionalPositional == null) + optionalPositional = t1.sEA; + if (named == null) + named = t1.sEA; + returnType = A._Parser_toType(t1, parser.e, head); + parameters = new A._FunctionParameters(); + parameters._requiredPositional = requiredPositional; + parameters._optionalPositional = optionalPositional; + parameters._named = named; + stack.push(A._Universe__lookupFunctionRti(t1, returnType, parameters)); + return; + case -4: + stack.push(A._Universe__lookupRecordRti(t1, stack.pop(), requiredPositional)); + return; + default: + throw A.wrapException(A.AssertionError$("Unexpected state under `()`: " + A.S(head))); + } + }, + _Parser_handleExtendedOperations(parser, stack) { + var $top = stack.pop(); + if (0 === $top) { + stack.push(A._Universe__lookupTerminalRti(parser.u, 1, "0&")); + return; + } + if (1 === $top) { + stack.push(A._Universe__lookupTerminalRti(parser.u, 4, "1&")); + return; + } + throw A.wrapException(A.AssertionError$("Unexpected extended operation " + A.S($top))); + }, + _Parser_collectArray(parser, stack) { + var array = stack.splice(parser.p); + A._Parser_toTypes(parser.u, parser.e, array); + parser.p = stack.pop(); + return array; + }, + _Parser_toType(universe, environment, item) { + if (typeof item == "string") + return A._Universe__lookupInterfaceRti(universe, item, universe.sEA); + else if (typeof item == "number") { + environment.toString; + return A._Parser_indexToType(universe, environment, item); + } else + return item; + }, + _Parser_toTypes(universe, environment, items) { + var i, + $length = items.length; + for (i = 0; i < $length; ++i) + items[i] = A._Parser_toType(universe, environment, items[i]); + }, + _Parser_toTypesNamed(universe, environment, items) { + var i, + $length = items.length; + for (i = 2; i < $length; i += 3) + items[i] = A._Parser_toType(universe, environment, items[i]); + }, + _Parser_indexToType(universe, environment, index) { + var typeArguments, len, + kind = environment._kind; + if (kind === 10) { + if (index === 0) + return environment._primary; + typeArguments = environment._rest; + len = typeArguments.length; + if (index <= len) + return typeArguments[index - 1]; + index -= len; + environment = environment._primary; + kind = environment._kind; + } else if (index === 0) + return environment; + if (kind !== 9) + throw A.wrapException(A.AssertionError$("Indexed base must be an interface type")); + typeArguments = environment._rest; + if (index <= typeArguments.length) + return typeArguments[index - 1]; + throw A.wrapException(A.AssertionError$("Bad index " + index + " for " + environment.toString$0(0))); + }, + isSubtype(universe, s, t) { + var result, + sCache = s._isSubtypeCache; + if (sCache == null) + sCache = s._isSubtypeCache = new Map(); + result = sCache.get(t); + if (result == null) { + result = A._isSubtype(universe, s, null, t, null, false) ? 1 : 0; + sCache.set(t, result); + } + if (0 === result) + return false; + if (1 === result) + return true; + return true; + }, + _isSubtype(universe, s, sEnv, t, tEnv, isLegacy) { + var t1, sKind, leftTypeVariable, tKind, t2, sBounds, tBounds, sLength, i, sBound, tBound; + if (s === t) + return true; + if (!A.isSoundTopType(t)) + t1 = t === type$.legacy_Object; + else + t1 = true; + if (t1) + return true; + sKind = s._kind; + if (sKind === 4) + return true; + if (A.isSoundTopType(s)) + return false; + t1 = s._kind; + if (t1 === 1) + return true; + leftTypeVariable = sKind === 14; + if (leftTypeVariable) + if (A._isSubtype(universe, sEnv[s._primary], sEnv, t, tEnv, false)) + return true; + tKind = t._kind; + t1 = s === type$.Null || s === type$.JSNull; + if (t1) { + if (tKind === 8) + return A._isSubtype(universe, s, sEnv, t._primary, tEnv, false); + return t === type$.Null || t === type$.JSNull || tKind === 7 || tKind === 6; + } + if (t === type$.Object) { + if (sKind === 8) + return A._isSubtype(universe, s._primary, sEnv, t, tEnv, false); + if (sKind === 6) + return A._isSubtype(universe, s._primary, sEnv, t, tEnv, false); + return sKind !== 7; + } + if (sKind === 6) + return A._isSubtype(universe, s._primary, sEnv, t, tEnv, false); + if (tKind === 6) { + t1 = A.Rti__getQuestionFromStar(universe, t); + return A._isSubtype(universe, s, sEnv, t1, tEnv, false); + } + if (sKind === 8) { + if (!A._isSubtype(universe, s._primary, sEnv, t, tEnv, false)) + return false; + return A._isSubtype(universe, A.Rti__getFutureFromFutureOr(universe, s), sEnv, t, tEnv, false); + } + if (sKind === 7) { + t1 = A._isSubtype(universe, type$.Null, sEnv, t, tEnv, false); + return t1 && A._isSubtype(universe, s._primary, sEnv, t, tEnv, false); + } + if (tKind === 8) { + if (A._isSubtype(universe, s, sEnv, t._primary, tEnv, false)) + return true; + return A._isSubtype(universe, s, sEnv, A.Rti__getFutureFromFutureOr(universe, t), tEnv, false); + } + if (tKind === 7) { + t1 = A._isSubtype(universe, s, sEnv, type$.Null, tEnv, false); + return t1 || A._isSubtype(universe, s, sEnv, t._primary, tEnv, false); + } + if (leftTypeVariable) + return false; + t1 = sKind !== 12; + if ((!t1 || sKind === 13) && t === type$.Function) + return true; + t2 = sKind === 11; + if (t2 && t === type$.Record) + return true; + if (tKind === 13) { + if (s === type$.JavaScriptFunction) + return true; + if (sKind !== 13) + return false; + sBounds = s._rest; + tBounds = t._rest; + sLength = sBounds.length; + if (sLength !== tBounds.length) + return false; + sEnv = sEnv == null ? sBounds : sBounds.concat(sEnv); + tEnv = tEnv == null ? tBounds : tBounds.concat(tEnv); + for (i = 0; i < sLength; ++i) { + sBound = sBounds[i]; + tBound = tBounds[i]; + if (!A._isSubtype(universe, sBound, sEnv, tBound, tEnv, false) || !A._isSubtype(universe, tBound, tEnv, sBound, sEnv, false)) + return false; + } + return A._isFunctionSubtype(universe, s._primary, sEnv, t._primary, tEnv, false); + } + if (tKind === 12) { + if (s === type$.JavaScriptFunction) + return true; + if (t1) + return false; + return A._isFunctionSubtype(universe, s, sEnv, t, tEnv, false); + } + if (sKind === 9) { + if (tKind !== 9) + return false; + return A._isInterfaceSubtype(universe, s, sEnv, t, tEnv, false); + } + if (t2 && tKind === 11) + return A._isRecordSubtype(universe, s, sEnv, t, tEnv, false); + return false; + }, + _isFunctionSubtype(universe, s, sEnv, t, tEnv, isLegacy) { + var sParameters, tParameters, sRequiredPositional, tRequiredPositional, sRequiredPositionalLength, tRequiredPositionalLength, requiredPositionalDelta, sOptionalPositional, tOptionalPositional, sOptionalPositionalLength, tOptionalPositionalLength, i, t1, sNamed, tNamed, sNamedLength, tNamedLength, sIndex, tIndex, tName, sName, sIsRequired; + if (!A._isSubtype(universe, s._primary, sEnv, t._primary, tEnv, false)) + return false; + sParameters = s._rest; + tParameters = t._rest; + sRequiredPositional = sParameters._requiredPositional; + tRequiredPositional = tParameters._requiredPositional; + sRequiredPositionalLength = sRequiredPositional.length; + tRequiredPositionalLength = tRequiredPositional.length; + if (sRequiredPositionalLength > tRequiredPositionalLength) + return false; + requiredPositionalDelta = tRequiredPositionalLength - sRequiredPositionalLength; + sOptionalPositional = sParameters._optionalPositional; + tOptionalPositional = tParameters._optionalPositional; + sOptionalPositionalLength = sOptionalPositional.length; + tOptionalPositionalLength = tOptionalPositional.length; + if (sRequiredPositionalLength + sOptionalPositionalLength < tRequiredPositionalLength + tOptionalPositionalLength) + return false; + for (i = 0; i < sRequiredPositionalLength; ++i) { + t1 = sRequiredPositional[i]; + if (!A._isSubtype(universe, tRequiredPositional[i], tEnv, t1, sEnv, false)) + return false; + } + for (i = 0; i < requiredPositionalDelta; ++i) { + t1 = sOptionalPositional[i]; + if (!A._isSubtype(universe, tRequiredPositional[sRequiredPositionalLength + i], tEnv, t1, sEnv, false)) + return false; + } + for (i = 0; i < tOptionalPositionalLength; ++i) { + t1 = sOptionalPositional[requiredPositionalDelta + i]; + if (!A._isSubtype(universe, tOptionalPositional[i], tEnv, t1, sEnv, false)) + return false; + } + sNamed = sParameters._named; + tNamed = tParameters._named; + sNamedLength = sNamed.length; + tNamedLength = tNamed.length; + for (sIndex = 0, tIndex = 0; tIndex < tNamedLength; tIndex += 3) { + tName = tNamed[tIndex]; + for (; true;) { + if (sIndex >= sNamedLength) + return false; + sName = sNamed[sIndex]; + sIndex += 3; + if (tName < sName) + return false; + sIsRequired = sNamed[sIndex - 2]; + if (sName < tName) { + if (sIsRequired) + return false; + continue; + } + t1 = tNamed[tIndex + 1]; + if (sIsRequired && !t1) + return false; + t1 = sNamed[sIndex - 1]; + if (!A._isSubtype(universe, tNamed[tIndex + 2], tEnv, t1, sEnv, false)) + return false; + break; + } + } + for (; sIndex < sNamedLength;) { + if (sNamed[sIndex + 1]) + return false; + sIndex += 3; + } + return true; + }, + _isInterfaceSubtype(universe, s, sEnv, t, tEnv, isLegacy) { + var rule, recipes, $length, supertypeArgs, i, + sName = s._primary, + tName = t._primary; + for (; sName !== tName;) { + rule = universe.tR[sName]; + if (rule == null) + return false; + if (typeof rule == "string") { + sName = rule; + continue; + } + recipes = rule[tName]; + if (recipes == null) + return false; + $length = recipes.length; + supertypeArgs = $length > 0 ? new Array($length) : init.typeUniverse.sEA; + for (i = 0; i < $length; ++i) + supertypeArgs[i] = A._Universe_evalInEnvironment(universe, s, recipes[i]); + return A._areArgumentsSubtypes(universe, supertypeArgs, null, sEnv, t._rest, tEnv, false); + } + return A._areArgumentsSubtypes(universe, s._rest, null, sEnv, t._rest, tEnv, false); + }, + _areArgumentsSubtypes(universe, sArgs, sVariances, sEnv, tArgs, tEnv, isLegacy) { + var i, + $length = sArgs.length; + for (i = 0; i < $length; ++i) + if (!A._isSubtype(universe, sArgs[i], sEnv, tArgs[i], tEnv, false)) + return false; + return true; + }, + _isRecordSubtype(universe, s, sEnv, t, tEnv, isLegacy) { + var i, + sFields = s._rest, + tFields = t._rest, + sCount = sFields.length; + if (sCount !== tFields.length) + return false; + if (s._primary !== t._primary) + return false; + for (i = 0; i < sCount; ++i) + if (!A._isSubtype(universe, sFields[i], sEnv, tFields[i], tEnv, false)) + return false; + return true; + }, + isNullable(t) { + var t1, + kind = t._kind; + if (!(t === type$.Null || t === type$.JSNull)) + if (!A.isSoundTopType(t)) + if (kind !== 7) + if (!(kind === 6 && A.isNullable(t._primary))) + t1 = kind === 8 && A.isNullable(t._primary); + else + t1 = true; + else + t1 = true; + else + t1 = true; + else + t1 = true; + return t1; + }, + isDefinitelyTopType(t) { + var t1; + if (!A.isSoundTopType(t)) + t1 = t === type$.legacy_Object; + else + t1 = true; + return t1; + }, + isSoundTopType(t) { + var kind = t._kind; + return kind === 2 || kind === 3 || kind === 4 || kind === 5 || t === type$.nullable_Object; + }, + _Utils_objectAssign(o, other) { + var i, key, + keys = Object.keys(other), + $length = keys.length; + for (i = 0; i < $length; ++i) { + key = keys[i]; + o[key] = other[key]; + } + }, + _Utils_newArrayOrEmpty($length) { + return $length > 0 ? new Array($length) : init.typeUniverse.sEA; + }, + Rti: function Rti(t0, t1) { + var _ = this; + _._as = t0; + _._is = t1; + _._cachedRuntimeType = _._specializedTestResource = _._isSubtypeCache = _._precomputed1 = null; + _._kind = 0; + _._canonicalRecipe = _._bindCache = _._evalCache = _._rest = _._primary = null; + }, + _FunctionParameters: function _FunctionParameters() { + this._named = this._optionalPositional = this._requiredPositional = null; + }, + _Type: function _Type(t0) { + this._rti = t0; + }, + _Error: function _Error() { + }, + _TypeError: function _TypeError(t0) { + this.__rti$_message = t0; + }, + _AsyncRun__initializeScheduleImmediate() { + var div, span, t1 = {}; + if (self.scheduleImmediate != null) + return A.async__AsyncRun__scheduleImmediateJsOverride$closure(); + if (self.MutationObserver != null && self.document != null) { + div = self.document.createElement("div"); + span = self.document.createElement("span"); + t1.storedCallback = null; + new self.MutationObserver(A.convertDartClosureToJS(new A._AsyncRun__initializeScheduleImmediate_internalCallback(t1), 1)).observe(div, {childList: true}); + return new A._AsyncRun__initializeScheduleImmediate_closure(t1, div, span); + } else if (self.setImmediate != null) + return A.async__AsyncRun__scheduleImmediateWithSetImmediate$closure(); + return A.async__AsyncRun__scheduleImmediateWithTimer$closure(); + }, + _AsyncRun__scheduleImmediateJsOverride(callback) { + self.scheduleImmediate(A.convertDartClosureToJS(new A._AsyncRun__scheduleImmediateJsOverride_internalCallback(type$.void_Function._as(callback)), 0)); + }, + _AsyncRun__scheduleImmediateWithSetImmediate(callback) { + self.setImmediate(A.convertDartClosureToJS(new A._AsyncRun__scheduleImmediateWithSetImmediate_internalCallback(type$.void_Function._as(callback)), 0)); + }, + _AsyncRun__scheduleImmediateWithTimer(callback) { + A.Timer__createTimer(B.Duration_0, type$.void_Function._as(callback)); + }, + Timer__createTimer(duration, callback) { + return A._TimerImpl$(duration._duration / 1000 | 0, callback); + }, + _TimerImpl$(milliseconds, callback) { + var t1 = new A._TimerImpl(); + t1._TimerImpl$2(milliseconds, callback); + return t1; + }, + _makeAsyncAwaitCompleter($T) { + return new A._AsyncAwaitCompleter(new A._Future($.Zone__current, $T._eval$1("_Future<0>")), $T._eval$1("_AsyncAwaitCompleter<0>")); + }, + _asyncStartSync(bodyFunction, completer) { + bodyFunction.call$2(0, null); + completer.isSync = true; + return completer._future; + }, + _asyncAwait(object, bodyFunction) { + A._awaitOnObject(object, bodyFunction); + }, + _asyncReturn(object, completer) { + completer.complete$1(object); + }, + _asyncRethrow(object, completer) { + completer.completeError$2(A.unwrapException(object), A.getTraceFromException(object)); + }, + _awaitOnObject(object, bodyFunction) { + var t1, future, + thenCallback = new A._awaitOnObject_closure(bodyFunction), + errorCallback = new A._awaitOnObject_closure0(bodyFunction); + if (object instanceof A._Future) + object._thenAwait$1$2(thenCallback, errorCallback, type$.dynamic); + else { + t1 = type$.dynamic; + if (object instanceof A._Future) + object.then$1$2$onError(thenCallback, errorCallback, t1); + else { + future = new A._Future($.Zone__current, type$._Future_dynamic); + future._state = 8; + future._resultOrListeners = object; + future._thenAwait$1$2(thenCallback, errorCallback, t1); + } + } + }, + _wrapJsFunctionForAsync($function) { + var $protected = function(fn, ERROR) { + return function(errorCode, result) { + while (true) { + try { + fn(errorCode, result); + break; + } catch (error) { + result = error; + errorCode = ERROR; + } + } + }; + }($function, 1); + return $.Zone__current.registerBinaryCallback$3$1(new A._wrapJsFunctionForAsync_closure($protected), type$.void, type$.int, type$.dynamic); + }, + AsyncError$(error, stackTrace) { + var t1 = A.checkNotNullable(error, "error", type$.Object); + return new A.AsyncError(t1, stackTrace == null ? A.AsyncError_defaultStackTrace(error) : stackTrace); + }, + AsyncError_defaultStackTrace(error) { + var stackTrace; + if (type$.Error._is(error)) { + stackTrace = error.get$stackTrace(); + if (stackTrace != null) + return stackTrace; + } + return B._StringStackTrace_3uE; + }, + Future_Future$sync(computation, $T) { + var result, error, stackTrace, future, replacement, t1, exception; + try { + result = computation.call$0(); + t1 = $T._eval$1("Future<0>")._is(result) ? result : A._Future$value(result, $T); + return t1; + } catch (exception) { + error = A.unwrapException(exception); + stackTrace = A.getTraceFromException(exception); + future = new A._Future($.Zone__current, $T._eval$1("_Future<0>")); + type$.Object._as(error); + type$.nullable_StackTrace._as(stackTrace); + replacement = null; + if (replacement != null) + future._asyncCompleteError$2(replacement.get$error(), replacement.get$stackTrace()); + else + future._asyncCompleteError$2(error, stackTrace); + return future; + } + }, + Future_Future$value(value, $T) { + var t1 = value == null ? $T._as(value) : value, + t2 = new A._Future($.Zone__current, $T._eval$1("_Future<0>")); + t2._asyncComplete$1(t1); + return t2; + }, + Completer_Completer($T) { + return new A._AsyncCompleter(new A._Future($.Zone__current, $T._eval$1("_Future<0>")), $T._eval$1("_AsyncCompleter<0>")); + }, + _Future$value(value, $T) { + var t1 = new A._Future($.Zone__current, $T._eval$1("_Future<0>")); + $T._as(value); + t1._state = 8; + t1._resultOrListeners = value; + return t1; + }, + _Future__chainCoreFutureSync(source, target) { + var t1, t2, listeners; + for (t1 = type$._Future_dynamic; t2 = source._state, (t2 & 4) !== 0;) + source = t1._as(source._resultOrListeners); + if ((t2 & 24) !== 0) { + listeners = target._removeListeners$0(); + target._cloneResult$1(source); + A._Future__propagateToListeners(target, listeners); + } else { + listeners = type$.nullable__FutureListener_dynamic_dynamic._as(target._resultOrListeners); + target._setChained$1(source); + source._prependListeners$1(listeners); + } + }, + _Future__chainCoreFutureAsync(source, target) { + var t2, t3, listeners, _box_0 = {}, + t1 = _box_0.source = source; + for (t2 = type$._Future_dynamic; t3 = t1._state, (t3 & 4) !== 0; t1 = source) { + source = t2._as(t1._resultOrListeners); + _box_0.source = source; + } + if ((t3 & 24) === 0) { + listeners = type$.nullable__FutureListener_dynamic_dynamic._as(target._resultOrListeners); + target._setChained$1(t1); + _box_0.source._prependListeners$1(listeners); + return; + } + if ((t3 & 16) === 0 && target._resultOrListeners == null) { + target._cloneResult$1(t1); + return; + } + target._state ^= 2; + A._rootScheduleMicrotask(null, null, target._zone, type$.void_Function._as(new A._Future__chainCoreFutureAsync_closure(_box_0, target))); + }, + _Future__propagateToListeners(source, listeners) { + var t2, t3, t4, _box_0, t5, t6, hasError, asyncError, nextListener, nextListener0, sourceResult, t7, zone, oldZone, result, current, _box_1 = {}, + t1 = _box_1.source = source; + for (t2 = type$.AsyncError, t3 = type$.nullable__FutureListener_dynamic_dynamic, t4 = type$.Future_dynamic; true;) { + _box_0 = {}; + t5 = t1._state; + t6 = (t5 & 16) === 0; + hasError = !t6; + if (listeners == null) { + if (hasError && (t5 & 1) === 0) { + asyncError = t2._as(t1._resultOrListeners); + A._rootHandleError(asyncError.error, asyncError.stackTrace); + } + return; + } + _box_0.listener = listeners; + nextListener = listeners._nextListener; + for (t1 = listeners; nextListener != null; t1 = nextListener, nextListener = nextListener0) { + t1._nextListener = null; + A._Future__propagateToListeners(_box_1.source, t1); + _box_0.listener = nextListener; + nextListener0 = nextListener._nextListener; + } + t5 = _box_1.source; + sourceResult = t5._resultOrListeners; + _box_0.listenerHasError = hasError; + _box_0.listenerValueOrError = sourceResult; + if (t6) { + t7 = t1.state; + t7 = (t7 & 1) !== 0 || (t7 & 15) === 8; + } else + t7 = true; + if (t7) { + zone = t1.result._zone; + if (hasError) { + t5 = t5._zone === zone; + t5 = !(t5 || t5); + } else + t5 = false; + if (t5) { + t2._as(sourceResult); + A._rootHandleError(sourceResult.error, sourceResult.stackTrace); + return; + } + oldZone = $.Zone__current; + if (oldZone !== zone) + $.Zone__current = zone; + else + oldZone = null; + t1 = t1.state; + if ((t1 & 15) === 8) + new A._Future__propagateToListeners_handleWhenCompleteCallback(_box_0, _box_1, hasError).call$0(); + else if (t6) { + if ((t1 & 1) !== 0) + new A._Future__propagateToListeners_handleValueCallback(_box_0, sourceResult).call$0(); + } else if ((t1 & 2) !== 0) + new A._Future__propagateToListeners_handleError(_box_1, _box_0).call$0(); + if (oldZone != null) + $.Zone__current = oldZone; + t1 = _box_0.listenerValueOrError; + if (t1 instanceof A._Future) { + t5 = _box_0.listener.$ti; + t5 = t5._eval$1("Future<2>")._is(t1) || !t5._rest[1]._is(t1); + } else + t5 = false; + if (t5) { + t4._as(t1); + result = _box_0.listener.result; + if ((t1._state & 24) !== 0) { + current = t3._as(result._resultOrListeners); + result._resultOrListeners = null; + listeners = result._reverseListeners$1(current); + result._state = t1._state & 30 | result._state & 1; + result._resultOrListeners = t1._resultOrListeners; + _box_1.source = t1; + continue; + } else + A._Future__chainCoreFutureSync(t1, result); + return; + } + } + result = _box_0.listener.result; + current = t3._as(result._resultOrListeners); + result._resultOrListeners = null; + listeners = result._reverseListeners$1(current); + t1 = _box_0.listenerHasError; + t5 = _box_0.listenerValueOrError; + if (!t1) { + result.$ti._precomputed1._as(t5); + result._state = 8; + result._resultOrListeners = t5; + } else { + t2._as(t5); + result._state = result._state & 1 | 16; + result._resultOrListeners = t5; + } + _box_1.source = result; + t1 = result; + } + }, + _registerErrorHandler(errorHandler, zone) { + var t1; + if (type$.dynamic_Function_Object_StackTrace._is(errorHandler)) + return zone.registerBinaryCallback$3$1(errorHandler, type$.dynamic, type$.Object, type$.StackTrace); + t1 = type$.dynamic_Function_Object; + if (t1._is(errorHandler)) + return t1._as(errorHandler); + throw A.wrapException(A.ArgumentError$value(errorHandler, "onError", string$.Error_)); + }, + _microtaskLoop() { + var entry, next; + for (entry = $._nextCallback; entry != null; entry = $._nextCallback) { + $._lastPriorityCallback = null; + next = entry.next; + $._nextCallback = next; + if (next == null) + $._lastCallback = null; + entry.callback.call$0(); + } + }, + _startMicrotaskLoop() { + $._isInCallbackLoop = true; + try { + A._microtaskLoop(); + } finally { + $._lastPriorityCallback = null; + $._isInCallbackLoop = false; + if ($._nextCallback != null) + $.$get$_AsyncRun__scheduleImmediateClosure().call$1(A.async___startMicrotaskLoop$closure()); + } + }, + _scheduleAsyncCallback(callback) { + var newEntry = new A._AsyncCallbackEntry(callback), + lastCallback = $._lastCallback; + if (lastCallback == null) { + $._nextCallback = $._lastCallback = newEntry; + if (!$._isInCallbackLoop) + $.$get$_AsyncRun__scheduleImmediateClosure().call$1(A.async___startMicrotaskLoop$closure()); + } else + $._lastCallback = lastCallback.next = newEntry; + }, + _schedulePriorityAsyncCallback(callback) { + var entry, lastPriorityCallback, next, + t1 = $._nextCallback; + if (t1 == null) { + A._scheduleAsyncCallback(callback); + $._lastPriorityCallback = $._lastCallback; + return; + } + entry = new A._AsyncCallbackEntry(callback); + lastPriorityCallback = $._lastPriorityCallback; + if (lastPriorityCallback == null) { + entry.next = t1; + $._nextCallback = $._lastPriorityCallback = entry; + } else { + next = lastPriorityCallback.next; + entry.next = next; + $._lastPriorityCallback = lastPriorityCallback.next = entry; + if (next == null) + $._lastCallback = entry; + } + }, + scheduleMicrotask(callback) { + var _null = null, + currentZone = $.Zone__current; + if (B.C__RootZone === currentZone) { + A._rootScheduleMicrotask(_null, _null, B.C__RootZone, callback); + return; + } + A._rootScheduleMicrotask(_null, _null, currentZone, type$.void_Function._as(currentZone.bindCallbackGuarded$1(callback))); + }, + StreamIterator_StreamIterator(stream, $T) { + A.checkNotNullable(stream, "stream", type$.Object); + return new A._StreamIterator($T._eval$1("_StreamIterator<0>")); + }, + StreamController_StreamController($T) { + var _null = null; + return new A._AsyncStreamController(_null, _null, _null, _null, $T._eval$1("_AsyncStreamController<0>")); + }, + _runGuarded(notificationHandler) { + return; + }, + _BufferingStreamSubscription__registerDataHandler(zone, handleData, $T) { + var t1 = handleData == null ? A.async___nullDataHandler$closure() : handleData; + return type$.$env_1_1_void._bind$1($T)._eval$1("1(2)")._as(t1); + }, + _BufferingStreamSubscription__registerErrorHandler(zone, handleError) { + if (handleError == null) + handleError = A.async___nullErrorHandler$closure(); + if (type$.void_Function_Object_StackTrace._is(handleError)) + return zone.registerBinaryCallback$3$1(handleError, type$.dynamic, type$.Object, type$.StackTrace); + if (type$.void_Function_Object._is(handleError)) + return type$.dynamic_Function_Object._as(handleError); + throw A.wrapException(A.ArgumentError$("handleError callback must take either an Object (the error), or both an Object (the error) and a StackTrace.", null)); + }, + _nullDataHandler(value) { + }, + _nullErrorHandler(error, stackTrace) { + A._rootHandleError(type$.Object._as(error), type$.StackTrace._as(stackTrace)); + }, + _nullDoneHandler() { + }, + _cancelAndValue(subscription, future, value) { + var cancelFuture = subscription.cancel$0(), + t1 = $.$get$Future__nullFuture(); + if (cancelFuture !== t1) + cancelFuture.whenComplete$1(new A._cancelAndValue_closure(future, value)); + else + future._complete$1(value); + }, + Timer_Timer(duration, callback) { + var t1 = $.Zone__current; + if (t1 === B.C__RootZone) + return A.Timer__createTimer(duration, type$.void_Function._as(callback)); + return A.Timer__createTimer(duration, type$.void_Function._as(t1.bindCallbackGuarded$1(callback))); + }, + _rootHandleError(error, stackTrace) { + A._schedulePriorityAsyncCallback(new A._rootHandleError_closure(error, stackTrace)); + }, + _rootRun($self, $parent, zone, f, $R) { + var old, + t1 = $.Zone__current; + if (t1 === zone) + return f.call$0(); + $.Zone__current = zone; + old = t1; + try { + t1 = f.call$0(); + return t1; + } finally { + $.Zone__current = old; + } + }, + _rootRunUnary($self, $parent, zone, f, arg, $R, $T) { + var old, + t1 = $.Zone__current; + if (t1 === zone) + return f.call$1(arg); + $.Zone__current = zone; + old = t1; + try { + t1 = f.call$1(arg); + return t1; + } finally { + $.Zone__current = old; + } + }, + _rootRunBinary($self, $parent, zone, f, arg1, arg2, $R, T1, T2) { + var old, + t1 = $.Zone__current; + if (t1 === zone) + return f.call$2(arg1, arg2); + $.Zone__current = zone; + old = t1; + try { + t1 = f.call$2(arg1, arg2); + return t1; + } finally { + $.Zone__current = old; + } + }, + _rootScheduleMicrotask($self, $parent, zone, f) { + type$.void_Function._as(f); + if (B.C__RootZone !== zone) + f = zone.bindCallbackGuarded$1(f); + A._scheduleAsyncCallback(f); + }, + _AsyncRun__initializeScheduleImmediate_internalCallback: function _AsyncRun__initializeScheduleImmediate_internalCallback(t0) { + this._box_0 = t0; + }, + _AsyncRun__initializeScheduleImmediate_closure: function _AsyncRun__initializeScheduleImmediate_closure(t0, t1, t2) { + this._box_0 = t0; + this.div = t1; + this.span = t2; + }, + _AsyncRun__scheduleImmediateJsOverride_internalCallback: function _AsyncRun__scheduleImmediateJsOverride_internalCallback(t0) { + this.callback = t0; + }, + _AsyncRun__scheduleImmediateWithSetImmediate_internalCallback: function _AsyncRun__scheduleImmediateWithSetImmediate_internalCallback(t0) { + this.callback = t0; + }, + _TimerImpl: function _TimerImpl() { + this._handle = null; + }, + _TimerImpl_internalCallback: function _TimerImpl_internalCallback(t0, t1) { + this.$this = t0; + this.callback = t1; + }, + _AsyncAwaitCompleter: function _AsyncAwaitCompleter(t0, t1) { + this._future = t0; + this.isSync = false; + this.$ti = t1; + }, + _awaitOnObject_closure: function _awaitOnObject_closure(t0) { + this.bodyFunction = t0; + }, + _awaitOnObject_closure0: function _awaitOnObject_closure0(t0) { + this.bodyFunction = t0; + }, + _wrapJsFunctionForAsync_closure: function _wrapJsFunctionForAsync_closure(t0) { + this.$protected = t0; + }, + AsyncError: function AsyncError(t0, t1) { + this.error = t0; + this.stackTrace = t1; + }, + _Completer: function _Completer() { + }, + _AsyncCompleter: function _AsyncCompleter(t0, t1) { + this.future = t0; + this.$ti = t1; + }, + _SyncCompleter: function _SyncCompleter(t0, t1) { + this.future = t0; + this.$ti = t1; + }, + _FutureListener: function _FutureListener(t0, t1, t2, t3, t4) { + var _ = this; + _._nextListener = null; + _.result = t0; + _.state = t1; + _.callback = t2; + _.errorCallback = t3; + _.$ti = t4; + }, + _Future: function _Future(t0, t1) { + var _ = this; + _._state = 0; + _._zone = t0; + _._resultOrListeners = null; + _.$ti = t1; + }, + _Future__addListener_closure: function _Future__addListener_closure(t0, t1) { + this.$this = t0; + this.listener = t1; + }, + _Future__prependListeners_closure: function _Future__prependListeners_closure(t0, t1) { + this._box_0 = t0; + this.$this = t1; + }, + _Future__chainForeignFuture_closure: function _Future__chainForeignFuture_closure(t0) { + this.$this = t0; + }, + _Future__chainForeignFuture_closure0: function _Future__chainForeignFuture_closure0(t0) { + this.$this = t0; + }, + _Future__chainForeignFuture_closure1: function _Future__chainForeignFuture_closure1(t0, t1, t2) { + this.$this = t0; + this.e = t1; + this.s = t2; + }, + _Future__chainCoreFutureAsync_closure: function _Future__chainCoreFutureAsync_closure(t0, t1) { + this._box_0 = t0; + this.target = t1; + }, + _Future__asyncCompleteWithValue_closure: function _Future__asyncCompleteWithValue_closure(t0, t1) { + this.$this = t0; + this.value = t1; + }, + _Future__asyncCompleteError_closure: function _Future__asyncCompleteError_closure(t0, t1, t2) { + this.$this = t0; + this.error = t1; + this.stackTrace = t2; + }, + _Future__propagateToListeners_handleWhenCompleteCallback: function _Future__propagateToListeners_handleWhenCompleteCallback(t0, t1, t2) { + this._box_0 = t0; + this._box_1 = t1; + this.hasError = t2; + }, + _Future__propagateToListeners_handleWhenCompleteCallback_closure: function _Future__propagateToListeners_handleWhenCompleteCallback_closure(t0) { + this.originalSource = t0; + }, + _Future__propagateToListeners_handleValueCallback: function _Future__propagateToListeners_handleValueCallback(t0, t1) { + this._box_0 = t0; + this.sourceResult = t1; + }, + _Future__propagateToListeners_handleError: function _Future__propagateToListeners_handleError(t0, t1) { + this._box_1 = t0; + this._box_0 = t1; + }, + _AsyncCallbackEntry: function _AsyncCallbackEntry(t0) { + this.callback = t0; + this.next = null; + }, + Stream: function Stream() { + }, + Stream_length_closure: function Stream_length_closure(t0, t1) { + this._box_0 = t0; + this.$this = t1; + }, + Stream_length_closure0: function Stream_length_closure0(t0, t1) { + this._box_0 = t0; + this.future = t1; + }, + Stream_first_closure: function Stream_first_closure(t0) { + this.future = t0; + }, + Stream_first_closure0: function Stream_first_closure0(t0, t1, t2) { + this.$this = t0; + this.subscription = t1; + this.future = t2; + }, + _StreamController: function _StreamController() { + }, + _StreamController__subscribe_closure: function _StreamController__subscribe_closure(t0) { + this.$this = t0; + }, + _StreamController__recordCancel_complete: function _StreamController__recordCancel_complete(t0) { + this.$this = t0; + }, + _AsyncStreamControllerDispatch: function _AsyncStreamControllerDispatch() { + }, + _AsyncStreamController: function _AsyncStreamController(t0, t1, t2, t3, t4) { + var _ = this; + _._varData = null; + _._state = 0; + _._doneFuture = null; + _.onListen = t0; + _.onPause = t1; + _.onResume = t2; + _.onCancel = t3; + _.$ti = t4; + }, + _ControllerStream: function _ControllerStream(t0, t1) { + this._controller = t0; + this.$ti = t1; + }, + _ControllerSubscription: function _ControllerSubscription(t0, t1, t2, t3, t4, t5, t6) { + var _ = this; + _._controller = t0; + _._onData = t1; + _._onError = t2; + _._onDone = t3; + _._zone = t4; + _._state = t5; + _._pending = _._cancelFuture = null; + _.$ti = t6; + }, + _StreamSinkWrapper: function _StreamSinkWrapper(t0, t1) { + this._async$_target = t0; + this.$ti = t1; + }, + _BufferingStreamSubscription: function _BufferingStreamSubscription() { + }, + _BufferingStreamSubscription_asFuture_closure: function _BufferingStreamSubscription_asFuture_closure(t0, t1) { + this._box_0 = t0; + this.result = t1; + }, + _BufferingStreamSubscription_asFuture_closure0: function _BufferingStreamSubscription_asFuture_closure0(t0, t1) { + this.$this = t0; + this.result = t1; + }, + _BufferingStreamSubscription_asFuture__closure: function _BufferingStreamSubscription_asFuture__closure(t0, t1, t2) { + this.result = t0; + this.error = t1; + this.stackTrace = t2; + }, + _BufferingStreamSubscription__sendError_sendError: function _BufferingStreamSubscription__sendError_sendError(t0, t1, t2) { + this.$this = t0; + this.error = t1; + this.stackTrace = t2; + }, + _BufferingStreamSubscription__sendDone_sendDone: function _BufferingStreamSubscription__sendDone_sendDone(t0) { + this.$this = t0; + }, + _StreamImpl: function _StreamImpl() { + }, + _DelayedEvent: function _DelayedEvent() { + }, + _DelayedData: function _DelayedData(t0, t1) { + this.value = t0; + this.next = null; + this.$ti = t1; + }, + _DelayedError: function _DelayedError(t0, t1) { + this.error = t0; + this.stackTrace = t1; + this.next = null; + }, + _DelayedDone: function _DelayedDone() { + }, + _PendingEvents: function _PendingEvents(t0) { + var _ = this; + _._state = 0; + _.lastPendingEvent = _.firstPendingEvent = null; + _.$ti = t0; + }, + _PendingEvents_schedule_closure: function _PendingEvents_schedule_closure(t0, t1) { + this.$this = t0; + this.dispatch = t1; + }, + _StreamIterator: function _StreamIterator(t0) { + this.$ti = t0; + }, + _cancelAndValue_closure: function _cancelAndValue_closure(t0, t1) { + this.future = t0; + this.value = t1; + }, + _Zone: function _Zone() { + }, + _rootHandleError_closure: function _rootHandleError_closure(t0, t1) { + this.error = t0; + this.stackTrace = t1; + }, + _RootZone: function _RootZone() { + }, + _RootZone_bindCallbackGuarded_closure: function _RootZone_bindCallbackGuarded_closure(t0, t1) { + this.$this = t0; + this.f = t1; + }, + _RootZone_bindUnaryCallbackGuarded_closure: function _RootZone_bindUnaryCallbackGuarded_closure(t0, t1, t2) { + this.$this = t0; + this.f = t1; + this.T = t2; + }, + _HashMap__getTableEntry(table, key) { + var entry = table[key]; + return entry === table ? null : entry; + }, + _HashMap__setTableEntry(table, key, value) { + if (value == null) + table[key] = table; + else + table[key] = value; + }, + _HashMap__newHashTable() { + var table = Object.create(null); + A._HashMap__setTableEntry(table, "", table); + delete table[""]; + return table; + }, + LinkedHashMap_LinkedHashMap$_empty($K, $V) { + return new A.JsLinkedHashMap($K._eval$1("@<0>")._bind$1($V)._eval$1("JsLinkedHashMap<1,2>")); + }, + MapBase_mapToString(m) { + var result, t1 = {}; + if (A.isToStringVisiting(m)) + return "{...}"; + result = new A.StringBuffer(""); + try { + B.JSArray_methods.add$1($.toStringVisiting, m); + result._contents += "{"; + t1.first = true; + m.forEach$1(0, new A.MapBase_mapToString_closure(t1, result)); + result._contents += "}"; + } finally { + if (0 >= $.toStringVisiting.length) + return A.ioore($.toStringVisiting, -1); + $.toStringVisiting.pop(); + } + t1 = result._contents; + return t1.charCodeAt(0) == 0 ? t1 : t1; + }, + ListQueue$($E) { + return new A.ListQueue(A.List_List$filled(A.ListQueue__calculateCapacity(null), null, false, $E._eval$1("0?")), $E._eval$1("ListQueue<0>")); + }, + ListQueue__calculateCapacity(initialCapacity) { + return 8; + }, + _HashMap: function _HashMap() { + }, + _IdentityHashMap: function _IdentityHashMap(t0) { + var _ = this; + _._collection$_length = 0; + _._collection$_keys = _._collection$_rest = _._collection$_nums = _._collection$_strings = null; + _.$ti = t0; + }, + _HashMapKeyIterable: function _HashMapKeyIterable(t0, t1) { + this._collection$_map = t0; + this.$ti = t1; + }, + _HashMapKeyIterator: function _HashMapKeyIterator(t0, t1, t2) { + var _ = this; + _._collection$_map = t0; + _._collection$_keys = t1; + _._offset = 0; + _._collection$_current = null; + _.$ti = t2; + }, + ListBase: function ListBase() { + }, + MapBase: function MapBase() { + }, + MapBase_mapToString_closure: function MapBase_mapToString_closure(t0, t1) { + this._box_0 = t0; + this.result = t1; + }, + _UnmodifiableMapMixin: function _UnmodifiableMapMixin() { + }, + MapView: function MapView() { + }, + UnmodifiableMapView: function UnmodifiableMapView() { + }, + ListQueue: function ListQueue(t0, t1) { + var _ = this; + _._table = t0; + _._modificationCount = _._tail = _._head = 0; + _.$ti = t1; + }, + _ListQueueIterator: function _ListQueueIterator(t0, t1, t2, t3, t4) { + var _ = this; + _._queue = t0; + _._end = t1; + _._modificationCount = t2; + _._position = t3; + _._collection$_current = null; + _.$ti = t4; + }, + _UnmodifiableMapView_MapView__UnmodifiableMapMixin: function _UnmodifiableMapView_MapView__UnmodifiableMapMixin() { + }, + _parseJson(source, reviver) { + var e, exception, t1, parsed = null; + try { + parsed = JSON.parse(source); + } catch (exception) { + e = A.unwrapException(exception); + t1 = A.FormatException$(String(e), null, null); + throw A.wrapException(t1); + } + t1 = A._convertJsonToDartLazy(parsed); + return t1; + }, + _convertJsonToDartLazy(object) { + var i; + if (object == null) + return null; + if (typeof object != "object") + return object; + if (Object.getPrototypeOf(object) !== Array.prototype) + return new A._JsonMap(object, Object.create(null)); + for (i = 0; i < object.length; ++i) + object[i] = A._convertJsonToDartLazy(object[i]); + return object; + }, + JsonUnsupportedObjectError$(unsupportedObject, cause, partialResult) { + return new A.JsonUnsupportedObjectError(unsupportedObject, cause); + }, + _defaultToEncodable(object) { + return object.toJson$0(); + }, + _JsonStringStringifier$(_sink, _toEncodable) { + return new A._JsonStringStringifier(_sink, [], A.convert___defaultToEncodable$closure()); + }, + _JsonStringStringifier_stringify(object, toEncodable, indent) { + var t1, + output = new A.StringBuffer(""), + stringifier = A._JsonStringStringifier$(output, toEncodable); + stringifier.writeObject$1(object); + t1 = output._contents; + return t1.charCodeAt(0) == 0 ? t1 : t1; + }, + _JsonMap: function _JsonMap(t0, t1) { + this._original = t0; + this._processed = t1; + this._data = null; + }, + _JsonMapKeyIterable: function _JsonMapKeyIterable(t0) { + this._parent = t0; + }, + Codec: function Codec() { + }, + Converter: function Converter() { + }, + JsonUnsupportedObjectError: function JsonUnsupportedObjectError(t0, t1) { + this.unsupportedObject = t0; + this.cause = t1; + }, + JsonCyclicError: function JsonCyclicError(t0, t1) { + this.unsupportedObject = t0; + this.cause = t1; + }, + JsonCodec: function JsonCodec() { + }, + JsonEncoder: function JsonEncoder(t0) { + this._toEncodable = t0; + }, + JsonDecoder: function JsonDecoder(t0) { + this._reviver = t0; + }, + _JsonStringifier: function _JsonStringifier() { + }, + _JsonStringifier_writeMap_closure: function _JsonStringifier_writeMap_closure(t0, t1) { + this._box_0 = t0; + this.keyValueList = t1; + }, + _JsonStringStringifier: function _JsonStringStringifier(t0, t1, t2) { + this._sink = t0; + this._seen = t1; + this._toEncodable = t2; + }, + int_parse(source, radix) { + var value = A.Primitives_parseInt(source, radix); + if (value != null) + return value; + throw A.wrapException(A.FormatException$(source, null, null)); + }, + Error__throw(error, stackTrace) { + error = A.wrapException(error); + if (error == null) + error = type$.Object._as(error); + error.stack = stackTrace.toString$0(0); + throw error; + throw A.wrapException("unreachable"); + }, + List_List$filled($length, fill, growable, $E) { + var i, + result = growable ? J.JSArray_JSArray$growable($length, $E) : J.JSArray_JSArray$fixed($length, $E); + if ($length !== 0 && fill != null) + for (i = 0; i < result.length; ++i) + result[i] = fill; + return result; + }, + List_List$of(elements, growable, $E) { + var t1 = A.List_List$_of(elements, $E); + return t1; + }, + List_List$_of(elements, $E) { + var list, t1; + if (Array.isArray(elements)) + return A._setArrayType(elements.slice(0), $E._eval$1("JSArray<0>")); + list = A._setArrayType([], $E._eval$1("JSArray<0>")); + for (t1 = J.get$iterator$ax(elements); t1.moveNext$0();) + B.JSArray_methods.add$1(list, t1.get$current()); + return list; + }, + StringBuffer__writeAll(string, objects, separator) { + var iterator = J.get$iterator$ax(objects); + if (!iterator.moveNext$0()) + return string; + if (separator.length === 0) { + do + string += A.S(iterator.get$current()); + while (iterator.moveNext$0()); + } else { + string += A.S(iterator.get$current()); + for (; iterator.moveNext$0();) + string = string + separator + A.S(iterator.get$current()); + } + return string; + }, + NoSuchMethodError_NoSuchMethodError$withInvocation(receiver, invocation) { + return new A.NoSuchMethodError(receiver, invocation.get$memberName(), invocation.get$positionalArguments(), invocation.get$namedArguments()); + }, + StackTrace_current() { + return A.getTraceFromException(new Error()); + }, + DateTime__fourDigits(n) { + var absN = Math.abs(n), + sign = n < 0 ? "-" : ""; + if (absN >= 1000) + return "" + n; + if (absN >= 100) + return sign + "0" + absN; + if (absN >= 10) + return sign + "00" + absN; + return sign + "000" + absN; + }, + DateTime__threeDigits(n) { + if (n >= 100) + return "" + n; + if (n >= 10) + return "0" + n; + return "00" + n; + }, + DateTime__twoDigits(n) { + if (n >= 10) + return "" + n; + return "0" + n; + }, + Error_safeToString(object) { + if (typeof object == "number" || A._isBool(object) || object == null) + return J.toString$0$(object); + if (typeof object == "string") + return JSON.stringify(object); + return A.Primitives_safeToString(object); + }, + Error_throwWithStackTrace(error, stackTrace) { + A.checkNotNullable(error, "error", type$.Object); + A.checkNotNullable(stackTrace, "stackTrace", type$.StackTrace); + A.Error__throw(error, stackTrace); + }, + AssertionError$(message) { + return new A.AssertionError(message); + }, + ArgumentError$(message, $name) { + return new A.ArgumentError(false, null, $name, message); + }, + ArgumentError$value(value, $name, message) { + return new A.ArgumentError(true, value, $name, message); + }, + ArgumentError$notNull($name) { + return new A.ArgumentError(false, null, $name, "Must not be null"); + }, + RangeError$(message) { + var _null = null; + return new A.RangeError(_null, _null, false, _null, _null, message); + }, + RangeError$value(value, $name) { + return new A.RangeError(null, null, true, value, $name, "Value not in range"); + }, + RangeError$range(invalidValue, minValue, maxValue, $name, message) { + return new A.RangeError(minValue, maxValue, true, invalidValue, $name, "Invalid value"); + }, + RangeError_checkValidRange(start, end, $length) { + if (0 > start || start > $length) + throw A.wrapException(A.RangeError$range(start, 0, $length, "start", null)); + if (end != null) { + if (start > end || end > $length) + throw A.wrapException(A.RangeError$range(end, start, $length, "end", null)); + return end; + } + return $length; + }, + RangeError_checkNotNegative(value, $name) { + if (value < 0) + throw A.wrapException(A.RangeError$range(value, 0, null, $name, null)); + return value; + }, + IndexError$withLength(invalidValue, $length, indexable, message, $name) { + return new A.IndexError($length, true, invalidValue, $name, "Index out of range"); + }, + UnsupportedError$(message) { + return new A.UnsupportedError(message); + }, + UnimplementedError$(message) { + return new A.UnimplementedError(message); + }, + StateError$(message) { + return new A.StateError(message); + }, + ConcurrentModificationError$(modifiedObject) { + return new A.ConcurrentModificationError(modifiedObject); + }, + FormatException$(message, source, offset) { + return new A.FormatException(message, source, offset); + }, + Iterable_iterableToShortString(iterable, leftDelimiter, rightDelimiter) { + var parts, t1; + if (A.isToStringVisiting(iterable)) { + if (leftDelimiter === "(" && rightDelimiter === ")") + return "(...)"; + return leftDelimiter + "..." + rightDelimiter; + } + parts = A._setArrayType([], type$.JSArray_String); + B.JSArray_methods.add$1($.toStringVisiting, iterable); + try { + A._iterablePartsToStrings(iterable, parts); + } finally { + if (0 >= $.toStringVisiting.length) + return A.ioore($.toStringVisiting, -1); + $.toStringVisiting.pop(); + } + t1 = A.StringBuffer__writeAll(leftDelimiter, type$.Iterable_dynamic._as(parts), ", ") + rightDelimiter; + return t1.charCodeAt(0) == 0 ? t1 : t1; + }, + Iterable_iterableToFullString(iterable, leftDelimiter, rightDelimiter) { + var buffer, t1; + if (A.isToStringVisiting(iterable)) + return leftDelimiter + "..." + rightDelimiter; + buffer = new A.StringBuffer(leftDelimiter); + B.JSArray_methods.add$1($.toStringVisiting, iterable); + try { + t1 = buffer; + t1._contents = A.StringBuffer__writeAll(t1._contents, iterable, ", "); + } finally { + if (0 >= $.toStringVisiting.length) + return A.ioore($.toStringVisiting, -1); + $.toStringVisiting.pop(); + } + buffer._contents += rightDelimiter; + t1 = buffer._contents; + return t1.charCodeAt(0) == 0 ? t1 : t1; + }, + _iterablePartsToStrings(iterable, parts) { + var next, ultimateString, penultimateString, penultimate, ultimate, ultimate0, elision, + it = iterable.get$iterator(iterable), + $length = 0, count = 0; + while (true) { + if (!($length < 80 || count < 3)) + break; + if (!it.moveNext$0()) + return; + next = A.S(it.get$current()); + B.JSArray_methods.add$1(parts, next); + $length += next.length + 2; + ++count; + } + if (!it.moveNext$0()) { + if (count <= 5) + return; + if (0 >= parts.length) + return A.ioore(parts, -1); + ultimateString = parts.pop(); + if (0 >= parts.length) + return A.ioore(parts, -1); + penultimateString = parts.pop(); + } else { + penultimate = it.get$current(); + ++count; + if (!it.moveNext$0()) { + if (count <= 4) { + B.JSArray_methods.add$1(parts, A.S(penultimate)); + return; + } + ultimateString = A.S(penultimate); + if (0 >= parts.length) + return A.ioore(parts, -1); + penultimateString = parts.pop(); + $length += ultimateString.length + 2; + } else { + ultimate = it.get$current(); + ++count; + for (; it.moveNext$0(); penultimate = ultimate, ultimate = ultimate0) { + ultimate0 = it.get$current(); + ++count; + if (count > 100) { + while (true) { + if (!($length > 75 && count > 3)) + break; + if (0 >= parts.length) + return A.ioore(parts, -1); + $length -= parts.pop().length + 2; + --count; + } + B.JSArray_methods.add$1(parts, "..."); + return; + } + } + penultimateString = A.S(penultimate); + ultimateString = A.S(ultimate); + $length += ultimateString.length + penultimateString.length + 4; + } + } + if (count > parts.length + 2) { + $length += 5; + elision = "..."; + } else + elision = null; + while (true) { + if (!($length > 80 && parts.length > 3)) + break; + if (0 >= parts.length) + return A.ioore(parts, -1); + $length -= parts.pop().length + 2; + if (elision == null) { + $length += 5; + elision = "..."; + } + } + if (elision != null) + B.JSArray_methods.add$1(parts, elision); + B.JSArray_methods.add$1(parts, penultimateString); + B.JSArray_methods.add$1(parts, ultimateString); + }, + NoSuchMethodError_toString_closure: function NoSuchMethodError_toString_closure(t0, t1) { + this._box_0 = t0; + this.sb = t1; + }, + DateTime: function DateTime(t0, t1) { + this._value = t0; + this.isUtc = t1; + }, + Duration: function Duration(t0) { + this._duration = t0; + }, + Error: function Error() { + }, + AssertionError: function AssertionError(t0) { + this.message = t0; + }, + TypeError: function TypeError() { + }, + ArgumentError: function ArgumentError(t0, t1, t2, t3) { + var _ = this; + _._hasValue = t0; + _.invalidValue = t1; + _.name = t2; + _.message = t3; + }, + RangeError: function RangeError(t0, t1, t2, t3, t4, t5) { + var _ = this; + _.start = t0; + _.end = t1; + _._hasValue = t2; + _.invalidValue = t3; + _.name = t4; + _.message = t5; + }, + IndexError: function IndexError(t0, t1, t2, t3, t4) { + var _ = this; + _.length = t0; + _._hasValue = t1; + _.invalidValue = t2; + _.name = t3; + _.message = t4; + }, + NoSuchMethodError: function NoSuchMethodError(t0, t1, t2, t3) { + var _ = this; + _._core$_receiver = t0; + _._core$_memberName = t1; + _._core$_arguments = t2; + _._namedArguments = t3; + }, + UnsupportedError: function UnsupportedError(t0) { + this.message = t0; + }, + UnimplementedError: function UnimplementedError(t0) { + this.message = t0; + }, + StateError: function StateError(t0) { + this.message = t0; + }, + ConcurrentModificationError: function ConcurrentModificationError(t0) { + this.modifiedObject = t0; + }, + OutOfMemoryError: function OutOfMemoryError() { + }, + StackOverflowError: function StackOverflowError() { + }, + _Exception: function _Exception(t0) { + this.message = t0; + }, + FormatException: function FormatException(t0, t1, t2) { + this.message = t0; + this.source = t1; + this.offset = t2; + }, + Iterable: function Iterable() { + }, + Null: function Null() { + }, + Object: function Object() { + }, + _StringStackTrace: function _StringStackTrace(t0) { + this._stackTrace = t0; + }, + StringBuffer: function StringBuffer(t0) { + this._contents = t0; + }, + _convertDartFunctionFast(f) { + var ret, + existing = f.$dart_jsFunction; + if (existing != null) + return existing; + ret = function(_call, f) { + return function() { + return _call(f, Array.prototype.slice.apply(arguments)); + }; + }(A._callDartFunctionFast, f); + ret[$.$get$DART_CLOSURE_PROPERTY_NAME()] = f; + f.$dart_jsFunction = ret; + return ret; + }, + _callDartFunctionFast(callback, $arguments) { + type$.List_dynamic._as($arguments); + type$.Function._as(callback); + return A.Primitives_applyFunction(callback, $arguments, null); + }, + allowInterop(f, $F) { + if (typeof f == "function") + return f; + else + return $F._as(A._convertDartFunctionFast(f)); + }, + promiseToFuture(jsPromise, $T) { + var t1 = new A._Future($.Zone__current, $T._eval$1("_Future<0>")), + completer = new A._AsyncCompleter(t1, $T._eval$1("_AsyncCompleter<0>")); + jsPromise.then(A.convertDartClosureToJS(new A.promiseToFuture_closure(completer, $T), 1), A.convertDartClosureToJS(new A.promiseToFuture_closure0(completer), 1)); + return t1; + }, + _noDartifyRequired(o) { + return o == null || typeof o === "boolean" || typeof o === "number" || typeof o === "string" || o instanceof Int8Array || o instanceof Uint8Array || o instanceof Uint8ClampedArray || o instanceof Int16Array || o instanceof Uint16Array || o instanceof Int32Array || o instanceof Uint32Array || o instanceof Float32Array || o instanceof Float64Array || o instanceof ArrayBuffer || o instanceof DataView; + }, + dartify(o) { + if (A._noDartifyRequired(o)) + return o; + return new A.dartify_convert(new A._IdentityHashMap(type$._IdentityHashMap_of_nullable_Object_and_nullable_Object)).call$1(o); + }, + promiseToFuture_closure: function promiseToFuture_closure(t0, t1) { + this.completer = t0; + this.T = t1; + }, + promiseToFuture_closure0: function promiseToFuture_closure0(t0) { + this.completer = t0; + }, + dartify_convert: function dartify_convert(t0) { + this._convertedObjects = t0; + }, + NullRejectionException: function NullRejectionException(t0) { + this.isUndefined = t0; + }, + _JSRandom: function _JSRandom() { + }, + AsyncMemoizer: function AsyncMemoizer(t0, t1) { + this._completer = t0; + this.$ti = t1; + }, + Level: function Level(t0, t1) { + this.name = t0; + this.value = t1; + }, + LogRecord: function LogRecord(t0, t1, t2) { + this.level = t0; + this.message = t1; + this.loggerName = t2; + }, + Logger_Logger($name) { + return $.Logger__loggers.putIfAbsent$2($name, new A.Logger_Logger_closure($name)); + }, + Logger: function Logger(t0, t1, t2) { + var _ = this; + _.name = t0; + _.parent = t1; + _._level = null; + _._children = t2; + }, + Logger_Logger_closure: function Logger_Logger_closure(t0) { + this.name = t0; + }, + Pool: function Pool(t0, t1, t2, t3, t4) { + var _ = this; + _._requestedResources = t0; + _._onReleaseCallbacks = t1; + _._onReleaseCompleters = t2; + _._maxAllocatedResources = t3; + _._allocatedResources = 0; + _._timer = null; + _._closeMemo = t4; + }, + Pool__runOnRelease_closure: function Pool__runOnRelease_closure(t0) { + this.$this = t0; + }, + Pool__runOnRelease_closure0: function Pool__runOnRelease_closure0(t0) { + this.$this = t0; + }, + PoolResource: function PoolResource(t0) { + this._pool = t0; + this._released = false; + }, + SseClient$(serverUrl) { + var t3, t4, t5, + t1 = type$.String, + t2 = A.StreamController_StreamController(t1); + t1 = A.StreamController_StreamController(t1); + t3 = A.Logger_Logger("SseClient"); + t4 = $.Zone__current; + t5 = A.generateUuidV4(); + t1 = new A.SseClient(t5, t2, t1, t3, new A._AsyncCompleter(new A._Future(t4, type$._Future_void), type$._AsyncCompleter_void)); + t1.SseClient$2$debugKey(serverUrl, null); + return t1; + }, + SseClient: function SseClient(t0, t1, t2, t3, t4) { + var _ = this; + _._clientId = t0; + _._incomingController = t1; + _._outgoingController = t2; + _._logger = t3; + _._onConnected = t4; + _._lastMessageId = -1; + _.__SseClient__serverUrl_A = _.__SseClient__eventSource_A = $; + _._errorTimer = null; + }, + SseClient_closure: function SseClient_closure(t0) { + this.$this = t0; + }, + SseClient_closure0: function SseClient_closure0(t0) { + this.$this = t0; + }, + SseClient_closure1: function SseClient_closure1(t0) { + this.$this = t0; + }, + SseClient__closure: function SseClient__closure(t0, t1) { + this.$this = t0; + this.error = t1; + }, + SseClient__onOutgoingMessage_closure: function SseClient__onOutgoingMessage_closure(t0, t1, t2) { + this._box_0 = t0; + this.$this = t1; + this.message = t2; + }, + generateUuidV4() { + var t1 = new A.generateUuidV4_printDigits(), + t2 = new A.generateUuidV4_bitsDigits(t1, new A.generateUuidV4_generateBits(B.C__JSRandom)), + t3 = B.C__JSRandom.nextInt$1(4); + return A.S(t2.call$2(16, 4)) + A.S(t2.call$2(16, 4)) + "-" + A.S(t2.call$2(16, 4)) + "-4" + A.S(t2.call$2(12, 3)) + "-" + A.S(t1.call$2(8 + t3, 1)) + A.S(t2.call$2(12, 3)) + "-" + A.S(t2.call$2(16, 4)) + A.S(t2.call$2(16, 4)) + A.S(t2.call$2(16, 4)); + }, + generateUuidV4_generateBits: function generateUuidV4_generateBits(t0) { + this.random = t0; + }, + generateUuidV4_printDigits: function generateUuidV4_printDigits() { + }, + generateUuidV4_bitsDigits: function generateUuidV4_bitsDigits(t0, t1) { + this.printDigits = t0; + this.generateBits = t1; + }, + StreamChannelMixin: function StreamChannelMixin() { + }, + _EventStreamSubscription$(_target, _eventType, onData, _useCapture, $T) { + var t1; + if (onData == null) + t1 = null; + else { + t1 = A._wrapZone(new A._EventStreamSubscription_closure(onData), type$.JSObject); + t1 = t1 == null ? null : type$.JavaScriptFunction._as(A.allowInterop(t1, type$.Function)); + } + t1 = new A._EventStreamSubscription(_target, _eventType, t1, false, $T._eval$1("_EventStreamSubscription<0>")); + t1._tryResume$0(); + return t1; + }, + _wrapZone(callback, $T) { + var t1 = $.Zone__current; + if (t1 === B.C__RootZone) + return callback; + return t1.bindUnaryCallbackGuarded$1$1(callback, $T); + }, + EventStreamProvider: function EventStreamProvider(t0, t1) { + this._eventType = t0; + this.$ti = t1; + }, + _EventStream: function _EventStream(t0, t1, t2, t3) { + var _ = this; + _._target = t0; + _._eventType = t1; + _._useCapture = t2; + _.$ti = t3; + }, + _ElementEventStreamImpl: function _ElementEventStreamImpl(t0, t1, t2, t3) { + var _ = this; + _._target = t0; + _._eventType = t1; + _._useCapture = t2; + _.$ti = t3; + }, + _EventStreamSubscription: function _EventStreamSubscription(t0, t1, t2, t3, t4) { + var _ = this; + _._target = t0; + _._eventType = t1; + _._streams$_onData = t2; + _._useCapture = t3; + _.$ti = t4; + }, + _EventStreamSubscription_closure: function _EventStreamSubscription_closure(t0) { + this.onData = t0; + }, + _EventStreamSubscription_onData_closure: function _EventStreamSubscription_onData_closure(t0) { + this.handleData = t0; + }, + main() { + var t2, + channel = A.SseClient$("/test"), + t1 = type$.nullable_JSObject._as(type$.JSObject._as(self.document).querySelector("button")); + t1.toString; + t2 = type$._ElementEventStreamImpl_JSObject; + A._EventStreamSubscription$(t1, "click", t2._eval$1("~(1)?")._as(new A.main_closure(channel)), false, t2._precomputed1); + t2 = channel._incomingController; + new A._ControllerStream(t2, A._instanceType(t2)._eval$1("_ControllerStream<1>")).listen$1(new A.main_closure0(channel)); + }, + main_closure: function main_closure(t0) { + this.channel = t0; + }, + main_closure0: function main_closure0(t0) { + this.channel = t0; + }, + throwLateFieldNI(fieldName) { + A.throwExpressionWithWrapper(new A.LateError("Field '" + fieldName + "' has not been initialized."), new Error()); + }, + throwLateFieldADI(fieldName) { + A.throwExpressionWithWrapper(new A.LateError("Field '" + fieldName + "' has been assigned during initialization."), new Error()); + } + }, + B = {}; + var holders = [A, J, B]; + var $ = {}; + A.JS_CONST.prototype = {}; + J.Interceptor.prototype = { + $eq(receiver, other) { + return receiver === other; + }, + get$hashCode(receiver) { + return A.Primitives_objectHashCode(receiver); + }, + toString$0(receiver) { + return "Instance of '" + A.Primitives_objectTypeName(receiver) + "'"; + }, + noSuchMethod$1(receiver, invocation) { + throw A.wrapException(A.NoSuchMethodError_NoSuchMethodError$withInvocation(receiver, type$.Invocation._as(invocation))); + }, + get$runtimeType(receiver) { + return A.createRuntimeType(A._instanceTypeFromConstructor(this)); + } + }; + J.JSBool.prototype = { + toString$0(receiver) { + return String(receiver); + }, + get$hashCode(receiver) { + return receiver ? 519018 : 218159; + }, + get$runtimeType(receiver) { + return A.createRuntimeType(type$.bool); + }, + $isTrustedGetRuntimeType: 1, + $isbool: 1 + }; + J.JSNull.prototype = { + $eq(receiver, other) { + return null == other; + }, + toString$0(receiver) { + return "null"; + }, + get$hashCode(receiver) { + return 0; + }, + $isTrustedGetRuntimeType: 1, + $isNull: 1 + }; + J.JavaScriptObject.prototype = {$isJSObject: 1}; + J.LegacyJavaScriptObject.prototype = { + get$hashCode(receiver) { + return 0; + }, + toString$0(receiver) { + return String(receiver); + } + }; + J.PlainJavaScriptObject.prototype = {}; + J.UnknownJavaScriptObject.prototype = {}; + J.JavaScriptFunction.prototype = { + toString$0(receiver) { + var dartClosure = receiver[$.$get$DART_CLOSURE_PROPERTY_NAME()]; + if (dartClosure == null) + return this.super$LegacyJavaScriptObject$toString(receiver); + return "JavaScript function for " + J.toString$0$(dartClosure); + }, + $isFunction: 1 + }; + J.JavaScriptBigInt.prototype = { + get$hashCode(receiver) { + return 0; + }, + toString$0(receiver) { + return String(receiver); + } + }; + J.JavaScriptSymbol.prototype = { + get$hashCode(receiver) { + return 0; + }, + toString$0(receiver) { + return String(receiver); + } + }; + J.JSArray.prototype = { + add$1(receiver, value) { + A._arrayInstanceType(receiver)._precomputed1._as(value); + if (!!receiver.fixed$length) + A.throwExpression(A.UnsupportedError$("add")); + receiver.push(value); + }, + addAll$1(receiver, collection) { + var t1; + A._arrayInstanceType(receiver)._eval$1("Iterable<1>")._as(collection); + if (!!receiver.fixed$length) + A.throwExpression(A.UnsupportedError$("addAll")); + if (Array.isArray(collection)) { + this._addAllFromArray$1(receiver, collection); + return; + } + for (t1 = J.get$iterator$ax(collection); t1.moveNext$0();) + receiver.push(t1.get$current()); + }, + _addAllFromArray$1(receiver, array) { + var len, i; + type$.JSArray_dynamic._as(array); + len = array.length; + if (len === 0) + return; + if (receiver === array) + throw A.wrapException(A.ConcurrentModificationError$(receiver)); + for (i = 0; i < len; ++i) + receiver.push(array[i]); + }, + get$last(receiver) { + var t1 = receiver.length; + if (t1 > 0) + return receiver[t1 - 1]; + throw A.wrapException(A.IterableElementError_noElement()); + }, + setRange$4(receiver, start, end, iterable, skipCount) { + var $length, otherList, t1, i; + A._arrayInstanceType(receiver)._eval$1("Iterable<1>")._as(iterable); + if (!!receiver.immutable$list) + A.throwExpression(A.UnsupportedError$("setRange")); + A.RangeError_checkValidRange(start, end, receiver.length); + $length = end - start; + if ($length === 0) + return; + A.RangeError_checkNotNegative(skipCount, "skipCount"); + otherList = iterable; + t1 = J.getInterceptor$asx(otherList); + if (skipCount + $length > t1.get$length(otherList)) + throw A.wrapException(A.IterableElementError_tooFew()); + if (skipCount < start) + for (i = $length - 1; i >= 0; --i) + receiver[start + i] = t1.$index(otherList, skipCount + i); + else + for (i = 0; i < $length; ++i) + receiver[start + i] = t1.$index(otherList, skipCount + i); + }, + get$isNotEmpty(receiver) { + return receiver.length !== 0; + }, + toString$0(receiver) { + return A.Iterable_iterableToFullString(receiver, "[", "]"); + }, + get$iterator(receiver) { + return new J.ArrayIterator(receiver, receiver.length, A._arrayInstanceType(receiver)._eval$1("ArrayIterator<1>")); + }, + get$hashCode(receiver) { + return A.Primitives_objectHashCode(receiver); + }, + get$length(receiver) { + return receiver.length; + }, + $index(receiver, index) { + if (!(index >= 0 && index < receiver.length)) + throw A.wrapException(A.diagnoseIndexError(receiver, index)); + return receiver[index]; + }, + $indexSet(receiver, index, value) { + A._arrayInstanceType(receiver)._precomputed1._as(value); + if (!!receiver.immutable$list) + A.throwExpression(A.UnsupportedError$("indexed set")); + if (!(index >= 0 && index < receiver.length)) + throw A.wrapException(A.diagnoseIndexError(receiver, index)); + receiver[index] = value; + }, + $isIterable: 1, + $isList: 1 + }; + J.JSUnmodifiableArray.prototype = {}; + J.ArrayIterator.prototype = { + get$current() { + var t1 = this._current; + return t1 == null ? this.$ti._precomputed1._as(t1) : t1; + }, + moveNext$0() { + var t2, _this = this, + t1 = _this._iterable, + $length = t1.length; + if (_this._length !== $length) { + t1 = A.throwConcurrentModificationError(t1); + throw A.wrapException(t1); + } + t2 = _this._index; + if (t2 >= $length) { + _this.set$_current(null); + return false; + } + _this.set$_current(t1[t2]); + ++_this._index; + return true; + }, + set$_current(_current) { + this._current = this.$ti._eval$1("1?")._as(_current); + } + }; + J.JSNumber.prototype = { + toRadixString$1(receiver, radix) { + var result, t1, t2, match, exponent; + if (radix < 2 || radix > 36) + throw A.wrapException(A.RangeError$range(radix, 2, 36, "radix", null)); + result = receiver.toString(radix); + t1 = result.length; + t2 = t1 - 1; + if (!(t2 >= 0)) + return A.ioore(result, t2); + if (result.charCodeAt(t2) !== 41) + return result; + match = /^([\da-z]+)(?:\.([\da-z]+))?\(e\+(\d+)\)$/.exec(result); + if (match == null) + A.throwExpression(A.UnsupportedError$("Unexpected toString result: " + result)); + t1 = match.length; + if (1 >= t1) + return A.ioore(match, 1); + result = match[1]; + if (3 >= t1) + return A.ioore(match, 3); + exponent = +match[3]; + t1 = match[2]; + if (t1 != null) { + result += t1; + exponent -= t1.length; + } + return result + B.JSString_methods.$mul("0", exponent); + }, + toString$0(receiver) { + if (receiver === 0 && 1 / receiver < 0) + return "-0.0"; + else + return "" + receiver; + }, + get$hashCode(receiver) { + var absolute, floorLog2, factor, scaled, + intValue = receiver | 0; + if (receiver === intValue) + return intValue & 536870911; + absolute = Math.abs(receiver); + floorLog2 = Math.log(absolute) / 0.6931471805599453 | 0; + factor = Math.pow(2, floorLog2); + scaled = absolute < 1 ? absolute / factor : factor / absolute; + return ((scaled * 9007199254740992 | 0) + (scaled * 3542243181176521 | 0)) * 599197 + floorLog2 * 1259 & 536870911; + }, + _tdivFast$1(receiver, other) { + return (receiver | 0) === receiver ? receiver / other | 0 : this._tdivSlow$1(receiver, other); + }, + _tdivSlow$1(receiver, other) { + var quotient = receiver / other; + if (quotient >= -2147483648 && quotient <= 2147483647) + return quotient | 0; + if (quotient > 0) { + if (quotient !== 1 / 0) + return Math.floor(quotient); + } else if (quotient > -1 / 0) + return Math.ceil(quotient); + throw A.wrapException(A.UnsupportedError$("Result of truncating division is " + A.S(quotient) + ": " + A.S(receiver) + " ~/ " + other)); + }, + _shlPositive$1(receiver, other) { + return other > 31 ? 0 : receiver << other >>> 0; + }, + _shrOtherPositive$1(receiver, other) { + var t1; + if (receiver > 0) + t1 = this._shrBothPositive$1(receiver, other); + else { + t1 = other > 31 ? 31 : other; + t1 = receiver >> t1 >>> 0; + } + return t1; + }, + _shrBothPositive$1(receiver, other) { + return other > 31 ? 0 : receiver >>> other; + }, + get$runtimeType(receiver) { + return A.createRuntimeType(type$.num); + }, + $isdouble: 1, + $isnum: 1 + }; + J.JSInt.prototype = { + get$runtimeType(receiver) { + return A.createRuntimeType(type$.int); + }, + $isTrustedGetRuntimeType: 1, + $isint: 1 + }; + J.JSNumNotInt.prototype = { + get$runtimeType(receiver) { + return A.createRuntimeType(type$.double); + }, + $isTrustedGetRuntimeType: 1 + }; + J.JSString.prototype = { + matchAsPrefix$2(receiver, string, start) { + var t1, t2, i, t3, _null = null; + if (start < 0 || start > string.length) + throw A.wrapException(A.RangeError$range(start, 0, string.length, _null, _null)); + t1 = receiver.length; + t2 = string.length; + if (start + t1 > t2) + return _null; + for (i = 0; i < t1; ++i) { + t3 = start + i; + if (!(t3 >= 0 && t3 < t2)) + return A.ioore(string, t3); + if (string.charCodeAt(t3) !== receiver.charCodeAt(i)) + return _null; + } + return new A.StringMatch(start, receiver); + }, + $add(receiver, other) { + return receiver + other; + }, + endsWith$1(receiver, other) { + var otherLength = other.length, + t1 = receiver.length; + if (otherLength > t1) + return false; + return other === this.substring$1(receiver, t1 - otherLength); + }, + startsWith$2(receiver, pattern, index) { + var endIndex; + if (index < 0 || index > receiver.length) + throw A.wrapException(A.RangeError$range(index, 0, receiver.length, null, null)); + if (typeof pattern == "string") { + endIndex = index + pattern.length; + if (endIndex > receiver.length) + return false; + return pattern === receiver.substring(index, endIndex); + } + return J.matchAsPrefix$2$s(pattern, receiver, index) != null; + }, + startsWith$1(receiver, pattern) { + return this.startsWith$2(receiver, pattern, 0); + }, + substring$2(receiver, start, end) { + return receiver.substring(start, A.RangeError_checkValidRange(start, end, receiver.length)); + }, + substring$1(receiver, start) { + return this.substring$2(receiver, start, null); + }, + $mul(receiver, times) { + var s, result; + if (0 >= times) + return ""; + if (times === 1 || receiver.length === 0) + return receiver; + if (times !== times >>> 0) + throw A.wrapException(B.C_OutOfMemoryError); + for (s = receiver, result = ""; true;) { + if ((times & 1) === 1) + result = s + result; + times = times >>> 1; + if (times === 0) + break; + s += s; + } + return result; + }, + padLeft$2(receiver, width, padding) { + var delta = width - receiver.length; + if (delta <= 0) + return receiver; + return this.$mul(padding, delta) + receiver; + }, + lastIndexOf$2(receiver, pattern, start) { + var t1, t2; + if (start == null) + start = receiver.length; + else if (start < 0 || start > receiver.length) + throw A.wrapException(A.RangeError$range(start, 0, receiver.length, null, null)); + t1 = pattern.length; + t2 = receiver.length; + if (start + t1 > t2) + start = t2 - t1; + return receiver.lastIndexOf(pattern, start); + }, + lastIndexOf$1(receiver, pattern) { + return this.lastIndexOf$2(receiver, pattern, null); + }, + toString$0(receiver) { + return receiver; + }, + get$hashCode(receiver) { + var t1, hash, i; + for (t1 = receiver.length, hash = 0, i = 0; i < t1; ++i) { + hash = hash + receiver.charCodeAt(i) & 536870911; + hash = hash + ((hash & 524287) << 10) & 536870911; + hash ^= hash >> 6; + } + hash = hash + ((hash & 67108863) << 3) & 536870911; + hash ^= hash >> 11; + return hash + ((hash & 16383) << 15) & 536870911; + }, + get$runtimeType(receiver) { + return A.createRuntimeType(type$.String); + }, + get$length(receiver) { + return receiver.length; + }, + $isTrustedGetRuntimeType: 1, + $isPattern: 1, + $isString: 1 + }; + A.LateError.prototype = { + toString$0(_) { + return "LateInitializationError: " + this._message; + } + }; + A.nullFuture_closure.prototype = { + call$0() { + return A.Future_Future$value(null, type$.Null); + }, + $signature: 7 + }; + A.EfficientLengthIterable.prototype = {}; + A.ListIterable.prototype = { + get$iterator(_) { + var _this = this; + return new A.ListIterator(_this, _this.get$length(_this), A._instanceType(_this)._eval$1("ListIterator")); + }, + get$isEmpty(_) { + return this.get$length(this) === 0; + } + }; + A.ListIterator.prototype = { + get$current() { + var t1 = this.__internal$_current; + return t1 == null ? this.$ti._precomputed1._as(t1) : t1; + }, + moveNext$0() { + var t3, _this = this, + t1 = _this.__internal$_iterable, + t2 = J.getInterceptor$asx(t1), + $length = t2.get$length(t1); + if (_this.__internal$_length !== $length) + throw A.wrapException(A.ConcurrentModificationError$(t1)); + t3 = _this.__internal$_index; + if (t3 >= $length) { + _this.set$__internal$_current(null); + return false; + } + _this.set$__internal$_current(t2.elementAt$1(t1, t3)); + ++_this.__internal$_index; + return true; + }, + set$__internal$_current(_current) { + this.__internal$_current = this.$ti._eval$1("1?")._as(_current); + } + }; + A.FixedLengthListMixin.prototype = {}; + A.Symbol.prototype = { + get$hashCode(_) { + var hash = this._hashCode; + if (hash != null) + return hash; + hash = 664597 * B.JSString_methods.get$hashCode(this._name) & 536870911; + this._hashCode = hash; + return hash; + }, + toString$0(_) { + return 'Symbol("' + this._name + '")'; + }, + $eq(_, other) { + if (other == null) + return false; + return other instanceof A.Symbol && this._name === other._name; + }, + $isSymbol0: 1 + }; + A.ConstantMapView.prototype = {}; + A.ConstantMap.prototype = { + get$isEmpty(_) { + return this.get$length(this) === 0; + }, + toString$0(_) { + return A.MapBase_mapToString(this); + }, + $isMap: 1 + }; + A.ConstantStringMap.prototype = { + get$length(_) { + return this._values.length; + }, + get$_keys() { + var keys = this.$keys; + if (keys == null) { + keys = Object.keys(this._jsIndex); + this.$keys = keys; + } + return keys; + }, + forEach$1(_, f) { + var keys, values, t1, i; + this.$ti._eval$1("~(1,2)")._as(f); + keys = this.get$_keys(); + values = this._values; + for (t1 = keys.length, i = 0; i < t1; ++i) + f.call$2(keys[i], values[i]); + } + }; + A.JSInvocationMirror.prototype = { + get$memberName() { + var t1 = this._memberName; + if (t1 instanceof A.Symbol) + return t1; + return this._memberName = new A.Symbol(A._asString(t1)); + }, + get$positionalArguments() { + var t1, t2, argumentCount, list, index, _this = this; + if (_this.__js_helper$_kind === 1) + return B.List_empty; + t1 = _this._arguments; + t2 = J.getInterceptor$asx(t1); + argumentCount = t2.get$length(t1) - J.get$length$asx(_this._namedArgumentNames) - _this._typeArgumentCount; + if (argumentCount === 0) + return B.List_empty; + list = []; + for (index = 0; index < argumentCount; ++index) + list.push(t2.$index(t1, index)); + return J.JSArray_markUnmodifiableList(list); + }, + get$namedArguments() { + var t1, t2, namedArgumentCount, t3, t4, namedArgumentsStartIndex, map, i, _this = this; + if (_this.__js_helper$_kind !== 0) + return B.Map_empty; + t1 = _this._namedArgumentNames; + t2 = J.getInterceptor$asx(t1); + namedArgumentCount = t2.get$length(t1); + t3 = _this._arguments; + t4 = J.getInterceptor$asx(t3); + namedArgumentsStartIndex = t4.get$length(t3) - namedArgumentCount - _this._typeArgumentCount; + if (namedArgumentCount === 0) + return B.Map_empty; + map = new A.JsLinkedHashMap(type$.JsLinkedHashMap_Symbol_dynamic); + for (i = 0; i < namedArgumentCount; ++i) + map.$indexSet(0, new A.Symbol(A._asString(t2.$index(t1, i))), t4.$index(t3, namedArgumentsStartIndex + i)); + return new A.ConstantMapView(map, type$.ConstantMapView_Symbol_dynamic); + }, + $isInvocation: 1 + }; + A.Primitives_functionNoSuchMethod_closure.prototype = { + call$2($name, argument) { + var t1; + A._asString($name); + t1 = this._box_0; + t1.names = t1.names + "$" + $name; + B.JSArray_methods.add$1(this.namedArgumentList, $name); + B.JSArray_methods.add$1(this.$arguments, argument); + ++t1.argumentCount; + }, + $signature: 12 + }; + A.TypeErrorDecoder.prototype = { + matchTypeError$1(message) { + var result, t1, _this = this, + match = new RegExp(_this._pattern).exec(message); + if (match == null) + return null; + result = Object.create(null); + t1 = _this._arguments; + if (t1 !== -1) + result.arguments = match[t1 + 1]; + t1 = _this._argumentsExpr; + if (t1 !== -1) + result.argumentsExpr = match[t1 + 1]; + t1 = _this._expr; + if (t1 !== -1) + result.expr = match[t1 + 1]; + t1 = _this._method; + if (t1 !== -1) + result.method = match[t1 + 1]; + t1 = _this._receiver; + if (t1 !== -1) + result.receiver = match[t1 + 1]; + return result; + } + }; + A.NullError.prototype = { + toString$0(_) { + return "Null check operator used on a null value"; + } + }; + A.JsNoSuchMethodError.prototype = { + toString$0(_) { + var t2, _this = this, + _s38_ = "NoSuchMethodError: method not found: '", + t1 = _this._method; + if (t1 == null) + return "NoSuchMethodError: " + _this.__js_helper$_message; + t2 = _this._receiver; + if (t2 == null) + return _s38_ + t1 + "' (" + _this.__js_helper$_message + ")"; + return _s38_ + t1 + "' on '" + t2 + "' (" + _this.__js_helper$_message + ")"; + } + }; + A.UnknownJsTypeError.prototype = { + toString$0(_) { + var t1 = this.__js_helper$_message; + return t1.length === 0 ? "Error" : "Error: " + t1; + } + }; + A.NullThrownFromJavaScriptException.prototype = { + toString$0(_) { + return "Throw of null ('" + (this._irritant === null ? "null" : "undefined") + "' from JavaScript)"; + } + }; + A.ExceptionAndStackTrace.prototype = {}; + A._StackTrace.prototype = { + toString$0(_) { + var trace, + t1 = this._trace; + if (t1 != null) + return t1; + t1 = this._exception; + trace = t1 !== null && typeof t1 === "object" ? t1.stack : null; + return this._trace = trace == null ? "" : trace; + }, + $isStackTrace: 1 + }; + A.Closure.prototype = { + toString$0(_) { + var $constructor = this.constructor, + $name = $constructor == null ? null : $constructor.name; + return "Closure '" + A.unminifyOrTag($name == null ? "unknown" : $name) + "'"; + }, + $isFunction: 1, + get$$call() { + return this; + }, + "call*": "call$1", + $requiredArgCount: 1, + $defaultValues: null + }; + A.Closure0Args.prototype = {"call*": "call$0", $requiredArgCount: 0}; + A.Closure2Args.prototype = {"call*": "call$2", $requiredArgCount: 2}; + A.TearOffClosure.prototype = {}; + A.StaticClosure.prototype = { + toString$0(_) { + var $name = this.$static_name; + if ($name == null) + return "Closure of unknown static method"; + return "Closure '" + A.unminifyOrTag($name) + "'"; + } + }; + A.BoundClosure.prototype = { + $eq(_, other) { + if (other == null) + return false; + if (this === other) + return true; + if (!(other instanceof A.BoundClosure)) + return false; + return this.$_target === other.$_target && this._receiver === other._receiver; + }, + get$hashCode(_) { + return (A.objectHashCode(this._receiver) ^ A.Primitives_objectHashCode(this.$_target)) >>> 0; + }, + toString$0(_) { + return "Closure '" + this.$_name + "' of " + ("Instance of '" + A.Primitives_objectTypeName(this._receiver) + "'"); + } + }; + A._CyclicInitializationError.prototype = { + toString$0(_) { + return "Reading static variable '" + this.variableName + "' during its initialization"; + } + }; + A.RuntimeError.prototype = { + toString$0(_) { + return "RuntimeError: " + this.message; + } + }; + A._Required.prototype = {}; + A.JsLinkedHashMap.prototype = { + get$length(_) { + return this.__js_helper$_length; + }, + get$isEmpty(_) { + return this.__js_helper$_length === 0; + }, + get$keys() { + return new A.LinkedHashMapKeyIterable(this, A._instanceType(this)._eval$1("LinkedHashMapKeyIterable<1>")); + }, + containsKey$1(key) { + var strings = this._strings; + if (strings == null) + return false; + return strings[key] != null; + }, + $index(_, key) { + var strings, cell, t1, nums, _null = null; + if (typeof key == "string") { + strings = this._strings; + if (strings == null) + return _null; + cell = strings[key]; + t1 = cell == null ? _null : cell.hashMapCellValue; + return t1; + } else if (typeof key == "number" && (key & 0x3fffffff) === key) { + nums = this._nums; + if (nums == null) + return _null; + cell = nums[key]; + t1 = cell == null ? _null : cell.hashMapCellValue; + return t1; + } else + return this.internalGet$1(key); + }, + internalGet$1(key) { + var bucket, index, + rest = this.__js_helper$_rest; + if (rest == null) + return null; + bucket = rest[this.internalComputeHashCode$1(key)]; + index = this.internalFindBucketIndex$2(bucket, key); + if (index < 0) + return null; + return bucket[index].hashMapCellValue; + }, + $indexSet(_, key, value) { + var strings, nums, rest, hash, bucket, index, _this = this, + t1 = A._instanceType(_this); + t1._precomputed1._as(key); + t1._rest[1]._as(value); + if (typeof key == "string") { + strings = _this._strings; + _this._addHashTableEntry$3(strings == null ? _this._strings = _this._newHashTable$0() : strings, key, value); + } else if (typeof key == "number" && (key & 0x3fffffff) === key) { + nums = _this._nums; + _this._addHashTableEntry$3(nums == null ? _this._nums = _this._newHashTable$0() : nums, key, value); + } else { + rest = _this.__js_helper$_rest; + if (rest == null) + rest = _this.__js_helper$_rest = _this._newHashTable$0(); + hash = _this.internalComputeHashCode$1(key); + bucket = rest[hash]; + if (bucket == null) + rest[hash] = [_this._newLinkedCell$2(key, value)]; + else { + index = _this.internalFindBucketIndex$2(bucket, key); + if (index >= 0) + bucket[index].hashMapCellValue = value; + else + bucket.push(_this._newLinkedCell$2(key, value)); + } + } + }, + putIfAbsent$2(key, ifAbsent) { + var t2, value, _this = this, + t1 = A._instanceType(_this); + t1._precomputed1._as(key); + t1._eval$1("2()")._as(ifAbsent); + if (_this.containsKey$1(key)) { + t2 = _this.$index(0, key); + return t2 == null ? t1._rest[1]._as(t2) : t2; + } + value = ifAbsent.call$0(); + _this.$indexSet(0, key, value); + return value; + }, + forEach$1(_, action) { + var cell, modifications, _this = this; + A._instanceType(_this)._eval$1("~(1,2)")._as(action); + cell = _this._first; + modifications = _this._modifications; + for (; cell != null;) { + action.call$2(cell.hashMapCellKey, cell.hashMapCellValue); + if (modifications !== _this._modifications) + throw A.wrapException(A.ConcurrentModificationError$(_this)); + cell = cell._next; + } + }, + _addHashTableEntry$3(table, key, value) { + var cell, + t1 = A._instanceType(this); + t1._precomputed1._as(key); + t1._rest[1]._as(value); + cell = table[key]; + if (cell == null) + table[key] = this._newLinkedCell$2(key, value); + else + cell.hashMapCellValue = value; + }, + _newLinkedCell$2(key, value) { + var _this = this, + t1 = A._instanceType(_this), + cell = new A.LinkedHashMapCell(t1._precomputed1._as(key), t1._rest[1]._as(value)); + if (_this._first == null) + _this._first = _this._last = cell; + else + _this._last = _this._last._next = cell; + ++_this.__js_helper$_length; + _this._modifications = _this._modifications + 1 & 1073741823; + return cell; + }, + internalComputeHashCode$1(key) { + return J.get$hashCode$(key) & 1073741823; + }, + internalFindBucketIndex$2(bucket, key) { + var $length, i; + if (bucket == null) + return -1; + $length = bucket.length; + for (i = 0; i < $length; ++i) + if (J.$eq$(bucket[i].hashMapCellKey, key)) + return i; + return -1; + }, + toString$0(_) { + return A.MapBase_mapToString(this); + }, + _newHashTable$0() { + var table = Object.create(null); + table[""] = table; + delete table[""]; + return table; + } + }; + A.LinkedHashMapCell.prototype = {}; + A.LinkedHashMapKeyIterable.prototype = { + get$length(_) { + return this._map.__js_helper$_length; + }, + get$isEmpty(_) { + return this._map.__js_helper$_length === 0; + }, + get$iterator(_) { + var t1 = this._map, + t2 = new A.LinkedHashMapKeyIterator(t1, t1._modifications, this.$ti._eval$1("LinkedHashMapKeyIterator<1>")); + t2._cell = t1._first; + return t2; + } + }; + A.LinkedHashMapKeyIterator.prototype = { + get$current() { + return this.__js_helper$_current; + }, + moveNext$0() { + var cell, _this = this, + t1 = _this._map; + if (_this._modifications !== t1._modifications) + throw A.wrapException(A.ConcurrentModificationError$(t1)); + cell = _this._cell; + if (cell == null) { + _this.set$__js_helper$_current(null); + return false; + } else { + _this.set$__js_helper$_current(cell.hashMapCellKey); + _this._cell = cell._next; + return true; + } + }, + set$__js_helper$_current(_current) { + this.__js_helper$_current = this.$ti._eval$1("1?")._as(_current); + } + }; + A.initHooks_closure.prototype = { + call$1(o) { + return this.getTag(o); + }, + $signature: 8 + }; + A.initHooks_closure0.prototype = { + call$2(o, tag) { + return this.getUnknownTag(o, tag); + }, + $signature: 13 + }; + A.initHooks_closure1.prototype = { + call$1(tag) { + return this.prototypeForTag(A._asString(tag)); + }, + $signature: 14 + }; + A.StringMatch.prototype = {}; + A.NativeByteBuffer.prototype = { + get$runtimeType(receiver) { + return B.Type_ByteBuffer_RkP; + }, + $isTrustedGetRuntimeType: 1 + }; + A.NativeTypedData.prototype = {}; + A.NativeByteData.prototype = { + get$runtimeType(receiver) { + return B.Type_ByteData_zNC; + }, + $isTrustedGetRuntimeType: 1 + }; + A.NativeTypedArray.prototype = { + get$length(receiver) { + return receiver.length; + }, + $isJavaScriptIndexingBehavior: 1 + }; + A.NativeTypedArrayOfDouble.prototype = { + $index(receiver, index) { + A._checkValidIndex(index, receiver, receiver.length); + return receiver[index]; + }, + $isIterable: 1, + $isList: 1 + }; + A.NativeTypedArrayOfInt.prototype = {$isIterable: 1, $isList: 1}; + A.NativeFloat32List.prototype = { + get$runtimeType(receiver) { + return B.Type_Float32List_LB7; + }, + $isTrustedGetRuntimeType: 1 + }; + A.NativeFloat64List.prototype = { + get$runtimeType(receiver) { + return B.Type_Float64List_LB7; + }, + $isTrustedGetRuntimeType: 1 + }; + A.NativeInt16List.prototype = { + get$runtimeType(receiver) { + return B.Type_Int16List_uXf; + }, + $index(receiver, index) { + A._checkValidIndex(index, receiver, receiver.length); + return receiver[index]; + }, + $isTrustedGetRuntimeType: 1 + }; + A.NativeInt32List.prototype = { + get$runtimeType(receiver) { + return B.Type_Int32List_O50; + }, + $index(receiver, index) { + A._checkValidIndex(index, receiver, receiver.length); + return receiver[index]; + }, + $isTrustedGetRuntimeType: 1 + }; + A.NativeInt8List.prototype = { + get$runtimeType(receiver) { + return B.Type_Int8List_ekJ; + }, + $index(receiver, index) { + A._checkValidIndex(index, receiver, receiver.length); + return receiver[index]; + }, + $isTrustedGetRuntimeType: 1 + }; + A.NativeUint16List.prototype = { + get$runtimeType(receiver) { + return B.Type_Uint16List_2bx; + }, + $index(receiver, index) { + A._checkValidIndex(index, receiver, receiver.length); + return receiver[index]; + }, + $isTrustedGetRuntimeType: 1 + }; + A.NativeUint32List.prototype = { + get$runtimeType(receiver) { + return B.Type_Uint32List_2bx; + }, + $index(receiver, index) { + A._checkValidIndex(index, receiver, receiver.length); + return receiver[index]; + }, + $isTrustedGetRuntimeType: 1 + }; + A.NativeUint8ClampedList.prototype = { + get$runtimeType(receiver) { + return B.Type_Uint8ClampedList_Jik; + }, + get$length(receiver) { + return receiver.length; + }, + $index(receiver, index) { + A._checkValidIndex(index, receiver, receiver.length); + return receiver[index]; + }, + $isTrustedGetRuntimeType: 1 + }; + A.NativeUint8List.prototype = { + get$runtimeType(receiver) { + return B.Type_Uint8List_WLA; + }, + get$length(receiver) { + return receiver.length; + }, + $index(receiver, index) { + A._checkValidIndex(index, receiver, receiver.length); + return receiver[index]; + }, + $isTrustedGetRuntimeType: 1 + }; + A._NativeTypedArrayOfDouble_NativeTypedArray_ListMixin.prototype = {}; + A._NativeTypedArrayOfDouble_NativeTypedArray_ListMixin_FixedLengthListMixin.prototype = {}; + A._NativeTypedArrayOfInt_NativeTypedArray_ListMixin.prototype = {}; + A._NativeTypedArrayOfInt_NativeTypedArray_ListMixin_FixedLengthListMixin.prototype = {}; + A.Rti.prototype = { + _eval$1(recipe) { + return A._Universe_evalInEnvironment(init.typeUniverse, this, recipe); + }, + _bind$1(typeOrTuple) { + return A._Universe_bind(init.typeUniverse, this, typeOrTuple); + } + }; + A._FunctionParameters.prototype = {}; + A._Type.prototype = { + toString$0(_) { + return A._rtiToString(this._rti, null); + } + }; + A._Error.prototype = { + toString$0(_) { + return this.__rti$_message; + } + }; + A._TypeError.prototype = {$isTypeError: 1}; + A._AsyncRun__initializeScheduleImmediate_internalCallback.prototype = { + call$1(_) { + var t1 = this._box_0, + f = t1.storedCallback; + t1.storedCallback = null; + f.call$0(); + }, + $signature: 4 + }; + A._AsyncRun__initializeScheduleImmediate_closure.prototype = { + call$1(callback) { + var t1, t2; + this._box_0.storedCallback = type$.void_Function._as(callback); + t1 = this.div; + t2 = this.span; + t1.firstChild ? t1.removeChild(t2) : t1.appendChild(t2); + }, + $signature: 15 + }; + A._AsyncRun__scheduleImmediateJsOverride_internalCallback.prototype = { + call$0() { + this.callback.call$0(); + }, + $signature: 2 + }; + A._AsyncRun__scheduleImmediateWithSetImmediate_internalCallback.prototype = { + call$0() { + this.callback.call$0(); + }, + $signature: 2 + }; + A._TimerImpl.prototype = { + _TimerImpl$2(milliseconds, callback) { + if (self.setTimeout != null) + this._handle = self.setTimeout(A.convertDartClosureToJS(new A._TimerImpl_internalCallback(this, callback), 0), milliseconds); + else + throw A.wrapException(A.UnsupportedError$("`setTimeout()` not found.")); + }, + cancel$0() { + if (self.setTimeout != null) { + var t1 = this._handle; + if (t1 == null) + return; + self.clearTimeout(t1); + this._handle = null; + } else + throw A.wrapException(A.UnsupportedError$("Canceling a timer.")); + }, + $isTimer: 1 + }; + A._TimerImpl_internalCallback.prototype = { + call$0() { + this.$this._handle = null; + this.callback.call$0(); + }, + $signature: 0 + }; + A._AsyncAwaitCompleter.prototype = { + complete$1(value) { + var t2, _this = this, + t1 = _this.$ti; + t1._eval$1("1/?")._as(value); + if (value == null) + value = t1._precomputed1._as(value); + if (!_this.isSync) + _this._future._asyncComplete$1(value); + else { + t2 = _this._future; + if (t1._eval$1("Future<1>")._is(value)) + t2._chainFuture$1(value); + else + t2._completeWithValue$1(value); + } + }, + completeError$2(e, st) { + var t1 = this._future; + if (this.isSync) + t1._completeError$2(e, st); + else + t1._asyncCompleteError$2(e, st); + }, + $isCompleter: 1 + }; + A._awaitOnObject_closure.prototype = { + call$1(result) { + return this.bodyFunction.call$2(0, result); + }, + $signature: 3 + }; + A._awaitOnObject_closure0.prototype = { + call$2(error, stackTrace) { + this.bodyFunction.call$2(1, new A.ExceptionAndStackTrace(error, type$.StackTrace._as(stackTrace))); + }, + $signature: 16 + }; + A._wrapJsFunctionForAsync_closure.prototype = { + call$2(errorCode, result) { + this.$protected(A._asInt(errorCode), result); + }, + $signature: 17 + }; + A.AsyncError.prototype = { + toString$0(_) { + return A.S(this.error); + }, + $isError: 1, + get$stackTrace() { + return this.stackTrace; + } + }; + A._Completer.prototype = { + completeError$2(error, stackTrace) { + A.checkNotNullable(error, "error", type$.Object); + if ((this.future._state & 30) !== 0) + throw A.wrapException(A.StateError$("Future already completed")); + if (stackTrace == null) + stackTrace = A.AsyncError_defaultStackTrace(error); + this._completeError$2(error, stackTrace); + }, + completeError$1(error) { + return this.completeError$2(error, null); + }, + $isCompleter: 1 + }; + A._AsyncCompleter.prototype = { + complete$1(value) { + var t2, + t1 = this.$ti; + t1._eval$1("1/?")._as(value); + t2 = this.future; + if ((t2._state & 30) !== 0) + throw A.wrapException(A.StateError$("Future already completed")); + t2._asyncComplete$1(t1._eval$1("1/")._as(value)); + }, + complete$0() { + return this.complete$1(null); + }, + _completeError$2(error, stackTrace) { + this.future._asyncCompleteError$2(error, stackTrace); + } + }; + A._SyncCompleter.prototype = { + complete$1(value) { + var t2, + t1 = this.$ti; + t1._eval$1("1/?")._as(value); + t2 = this.future; + if ((t2._state & 30) !== 0) + throw A.wrapException(A.StateError$("Future already completed")); + t2._complete$1(t1._eval$1("1/")._as(value)); + }, + _completeError$2(error, stackTrace) { + this.future._completeError$2(error, stackTrace); + } + }; + A._FutureListener.prototype = { + matchesErrorTest$1(asyncError) { + if ((this.state & 15) !== 6) + return true; + return this.result._zone.runUnary$2$2(type$.bool_Function_Object._as(this.callback), asyncError.error, type$.bool, type$.Object); + }, + handleError$1(asyncError) { + var exception, _this = this, + errorCallback = _this.errorCallback, + result = null, + t1 = type$.dynamic, + t2 = type$.Object, + t3 = asyncError.error, + t4 = _this.result._zone; + if (type$.dynamic_Function_Object_StackTrace._is(errorCallback)) + result = t4.runBinary$3$3(errorCallback, t3, asyncError.stackTrace, t1, t2, type$.StackTrace); + else + result = t4.runUnary$2$2(type$.dynamic_Function_Object._as(errorCallback), t3, t1, t2); + try { + t1 = _this.$ti._eval$1("2/")._as(result); + return t1; + } catch (exception) { + if (type$.TypeError._is(A.unwrapException(exception))) { + if ((_this.state & 1) !== 0) + throw A.wrapException(A.ArgumentError$("The error handler of Future.then must return a value of the returned future's type", "onError")); + throw A.wrapException(A.ArgumentError$("The error handler of Future.catchError must return a value of the future's type", "onError")); + } else + throw exception; + } + } + }; + A._Future.prototype = { + _setChained$1(source) { + this._state = this._state & 1 | 4; + this._resultOrListeners = source; + }, + then$1$2$onError(f, onError, $R) { + var currentZone, result, t2, + t1 = this.$ti; + t1._bind$1($R)._eval$1("1/(2)")._as(f); + currentZone = $.Zone__current; + if (currentZone === B.C__RootZone) { + if (onError != null && !type$.dynamic_Function_Object_StackTrace._is(onError) && !type$.dynamic_Function_Object._is(onError)) + throw A.wrapException(A.ArgumentError$value(onError, "onError", string$.Error_)); + } else { + $R._eval$1("@<0/>")._bind$1(t1._precomputed1)._eval$1("1(2)")._as(f); + if (onError != null) + onError = A._registerErrorHandler(onError, currentZone); + } + result = new A._Future(currentZone, $R._eval$1("_Future<0>")); + t2 = onError == null ? 1 : 3; + this._addListener$1(new A._FutureListener(result, t2, f, onError, t1._eval$1("@<1>")._bind$1($R)._eval$1("_FutureListener<1,2>"))); + return result; + }, + then$1$1(f, $R) { + return this.then$1$2$onError(f, null, $R); + }, + _thenAwait$1$2(f, onError, $E) { + var result, + t1 = this.$ti; + t1._bind$1($E)._eval$1("1/(2)")._as(f); + result = new A._Future($.Zone__current, $E._eval$1("_Future<0>")); + this._addListener$1(new A._FutureListener(result, 19, f, onError, t1._eval$1("@<1>")._bind$1($E)._eval$1("_FutureListener<1,2>"))); + return result; + }, + whenComplete$1(action) { + var t1, result; + type$.dynamic_Function._as(action); + t1 = this.$ti; + result = new A._Future($.Zone__current, t1); + this._addListener$1(new A._FutureListener(result, 8, action, null, t1._eval$1("@<1>")._bind$1(t1._precomputed1)._eval$1("_FutureListener<1,2>"))); + return result; + }, + _setErrorObject$1(error) { + this._state = this._state & 1 | 16; + this._resultOrListeners = error; + }, + _cloneResult$1(source) { + this._state = source._state & 30 | this._state & 1; + this._resultOrListeners = source._resultOrListeners; + }, + _addListener$1(listener) { + var source, _this = this, + t1 = _this._state; + if (t1 <= 3) { + listener._nextListener = type$.nullable__FutureListener_dynamic_dynamic._as(_this._resultOrListeners); + _this._resultOrListeners = listener; + } else { + if ((t1 & 4) !== 0) { + source = type$._Future_dynamic._as(_this._resultOrListeners); + if ((source._state & 24) === 0) { + source._addListener$1(listener); + return; + } + _this._cloneResult$1(source); + } + A._rootScheduleMicrotask(null, null, _this._zone, type$.void_Function._as(new A._Future__addListener_closure(_this, listener))); + } + }, + _prependListeners$1(listeners) { + var t1, existingListeners, next, cursor, next0, source, _this = this, _box_0 = {}; + _box_0.listeners = listeners; + if (listeners == null) + return; + t1 = _this._state; + if (t1 <= 3) { + existingListeners = type$.nullable__FutureListener_dynamic_dynamic._as(_this._resultOrListeners); + _this._resultOrListeners = listeners; + if (existingListeners != null) { + next = listeners._nextListener; + for (cursor = listeners; next != null; cursor = next, next = next0) + next0 = next._nextListener; + cursor._nextListener = existingListeners; + } + } else { + if ((t1 & 4) !== 0) { + source = type$._Future_dynamic._as(_this._resultOrListeners); + if ((source._state & 24) === 0) { + source._prependListeners$1(listeners); + return; + } + _this._cloneResult$1(source); + } + _box_0.listeners = _this._reverseListeners$1(listeners); + A._rootScheduleMicrotask(null, null, _this._zone, type$.void_Function._as(new A._Future__prependListeners_closure(_box_0, _this))); + } + }, + _removeListeners$0() { + var current = type$.nullable__FutureListener_dynamic_dynamic._as(this._resultOrListeners); + this._resultOrListeners = null; + return this._reverseListeners$1(current); + }, + _reverseListeners$1(listeners) { + var current, prev, next; + for (current = listeners, prev = null; current != null; prev = current, current = next) { + next = current._nextListener; + current._nextListener = prev; + } + return prev; + }, + _chainForeignFuture$1(source) { + var e, s, exception, _this = this; + _this._state ^= 2; + try { + source.then$1$2$onError(new A._Future__chainForeignFuture_closure(_this), new A._Future__chainForeignFuture_closure0(_this), type$.Null); + } catch (exception) { + e = A.unwrapException(exception); + s = A.getTraceFromException(exception); + A.scheduleMicrotask(new A._Future__chainForeignFuture_closure1(_this, e, s)); + } + }, + _complete$1(value) { + var listeners, _this = this, + t1 = _this.$ti; + t1._eval$1("1/")._as(value); + if (t1._eval$1("Future<1>")._is(value)) + if (t1._is(value)) + A._Future__chainCoreFutureSync(value, _this); + else + _this._chainForeignFuture$1(value); + else { + listeners = _this._removeListeners$0(); + t1._precomputed1._as(value); + _this._state = 8; + _this._resultOrListeners = value; + A._Future__propagateToListeners(_this, listeners); + } + }, + _completeWithValue$1(value) { + var listeners, _this = this; + _this.$ti._precomputed1._as(value); + listeners = _this._removeListeners$0(); + _this._state = 8; + _this._resultOrListeners = value; + A._Future__propagateToListeners(_this, listeners); + }, + _completeError$2(error, stackTrace) { + var listeners; + type$.Object._as(error); + type$.StackTrace._as(stackTrace); + listeners = this._removeListeners$0(); + this._setErrorObject$1(A.AsyncError$(error, stackTrace)); + A._Future__propagateToListeners(this, listeners); + }, + _asyncComplete$1(value) { + var t1 = this.$ti; + t1._eval$1("1/")._as(value); + if (t1._eval$1("Future<1>")._is(value)) { + this._chainFuture$1(value); + return; + } + this._asyncCompleteWithValue$1(value); + }, + _asyncCompleteWithValue$1(value) { + var _this = this; + _this.$ti._precomputed1._as(value); + _this._state ^= 2; + A._rootScheduleMicrotask(null, null, _this._zone, type$.void_Function._as(new A._Future__asyncCompleteWithValue_closure(_this, value))); + }, + _chainFuture$1(value) { + var t1 = this.$ti; + t1._eval$1("Future<1>")._as(value); + if (t1._is(value)) { + A._Future__chainCoreFutureAsync(value, this); + return; + } + this._chainForeignFuture$1(value); + }, + _asyncCompleteError$2(error, stackTrace) { + type$.StackTrace._as(stackTrace); + this._state ^= 2; + A._rootScheduleMicrotask(null, null, this._zone, type$.void_Function._as(new A._Future__asyncCompleteError_closure(this, error, stackTrace))); + }, + $isFuture: 1 + }; + A._Future__addListener_closure.prototype = { + call$0() { + A._Future__propagateToListeners(this.$this, this.listener); + }, + $signature: 0 + }; + A._Future__prependListeners_closure.prototype = { + call$0() { + A._Future__propagateToListeners(this.$this, this._box_0.listeners); + }, + $signature: 0 + }; + A._Future__chainForeignFuture_closure.prototype = { + call$1(value) { + var error, stackTrace, exception, + t1 = this.$this; + t1._state ^= 2; + try { + t1._completeWithValue$1(t1.$ti._precomputed1._as(value)); + } catch (exception) { + error = A.unwrapException(exception); + stackTrace = A.getTraceFromException(exception); + t1._completeError$2(error, stackTrace); + } + }, + $signature: 4 + }; + A._Future__chainForeignFuture_closure0.prototype = { + call$2(error, stackTrace) { + this.$this._completeError$2(type$.Object._as(error), type$.StackTrace._as(stackTrace)); + }, + $signature: 5 + }; + A._Future__chainForeignFuture_closure1.prototype = { + call$0() { + this.$this._completeError$2(this.e, this.s); + }, + $signature: 0 + }; + A._Future__chainCoreFutureAsync_closure.prototype = { + call$0() { + A._Future__chainCoreFutureSync(this._box_0.source, this.target); + }, + $signature: 0 + }; + A._Future__asyncCompleteWithValue_closure.prototype = { + call$0() { + this.$this._completeWithValue$1(this.value); + }, + $signature: 0 + }; + A._Future__asyncCompleteError_closure.prototype = { + call$0() { + this.$this._completeError$2(this.error, this.stackTrace); + }, + $signature: 0 + }; + A._Future__propagateToListeners_handleWhenCompleteCallback.prototype = { + call$0() { + var e, s, t1, exception, t2, originalSource, _this = this, completeResult = null; + try { + t1 = _this._box_0.listener; + completeResult = t1.result._zone.run$1$1(type$.dynamic_Function._as(t1.callback), type$.dynamic); + } catch (exception) { + e = A.unwrapException(exception); + s = A.getTraceFromException(exception); + t1 = _this.hasError && type$.AsyncError._as(_this._box_1.source._resultOrListeners).error === e; + t2 = _this._box_0; + if (t1) + t2.listenerValueOrError = type$.AsyncError._as(_this._box_1.source._resultOrListeners); + else + t2.listenerValueOrError = A.AsyncError$(e, s); + t2.listenerHasError = true; + return; + } + if (completeResult instanceof A._Future && (completeResult._state & 24) !== 0) { + if ((completeResult._state & 16) !== 0) { + t1 = _this._box_0; + t1.listenerValueOrError = type$.AsyncError._as(completeResult._resultOrListeners); + t1.listenerHasError = true; + } + return; + } + if (completeResult instanceof A._Future) { + originalSource = _this._box_1.source; + t1 = _this._box_0; + t1.listenerValueOrError = completeResult.then$1$1(new A._Future__propagateToListeners_handleWhenCompleteCallback_closure(originalSource), type$.dynamic); + t1.listenerHasError = false; + } + }, + $signature: 0 + }; + A._Future__propagateToListeners_handleWhenCompleteCallback_closure.prototype = { + call$1(_) { + return this.originalSource; + }, + $signature: 18 + }; + A._Future__propagateToListeners_handleValueCallback.prototype = { + call$0() { + var e, s, t1, t2, t3, t4, t5, exception; + try { + t1 = this._box_0; + t2 = t1.listener; + t3 = t2.$ti; + t4 = t3._precomputed1; + t5 = t4._as(this.sourceResult); + t1.listenerValueOrError = t2.result._zone.runUnary$2$2(t3._eval$1("2/(1)")._as(t2.callback), t5, t3._eval$1("2/"), t4); + } catch (exception) { + e = A.unwrapException(exception); + s = A.getTraceFromException(exception); + t1 = this._box_0; + t1.listenerValueOrError = A.AsyncError$(e, s); + t1.listenerHasError = true; + } + }, + $signature: 0 + }; + A._Future__propagateToListeners_handleError.prototype = { + call$0() { + var asyncError, e, s, t1, exception, t2, _this = this; + try { + asyncError = type$.AsyncError._as(_this._box_1.source._resultOrListeners); + t1 = _this._box_0; + if (t1.listener.matchesErrorTest$1(asyncError) && t1.listener.errorCallback != null) { + t1.listenerValueOrError = t1.listener.handleError$1(asyncError); + t1.listenerHasError = false; + } + } catch (exception) { + e = A.unwrapException(exception); + s = A.getTraceFromException(exception); + t1 = type$.AsyncError._as(_this._box_1.source._resultOrListeners); + t2 = _this._box_0; + if (t1.error === e) + t2.listenerValueOrError = t1; + else + t2.listenerValueOrError = A.AsyncError$(e, s); + t2.listenerHasError = true; + } + }, + $signature: 0 + }; + A._AsyncCallbackEntry.prototype = {}; + A.Stream.prototype = { + get$length(_) { + var t1 = {}, + future = new A._Future($.Zone__current, type$._Future_int); + t1.count = 0; + this.listen$4$cancelOnError$onDone$onError(new A.Stream_length_closure(t1, this), true, new A.Stream_length_closure0(t1, future), future.get$_completeError()); + return future; + }, + get$first(_) { + var future = new A._Future($.Zone__current, A._instanceType(this)._eval$1("_Future<1>")), + subscription = this.listen$4$cancelOnError$onDone$onError(null, true, new A.Stream_first_closure(future), future.get$_completeError()); + subscription.onData$1(new A.Stream_first_closure0(this, subscription, future)); + return future; + } + }; + A.Stream_length_closure.prototype = { + call$1(_) { + A._instanceType(this.$this)._precomputed1._as(_); + ++this._box_0.count; + }, + $signature() { + return A._instanceType(this.$this)._eval$1("~(1)"); + } + }; + A.Stream_length_closure0.prototype = { + call$0() { + this.future._complete$1(this._box_0.count); + }, + $signature: 0 + }; + A.Stream_first_closure.prototype = { + call$0() { + var e, s, t1, exception, stackTrace; + try { + t1 = A.IterableElementError_noElement(); + throw A.wrapException(t1); + } catch (exception) { + e = A.unwrapException(exception); + s = A.getTraceFromException(exception); + t1 = e; + stackTrace = s; + if (stackTrace == null) + stackTrace = A.AsyncError_defaultStackTrace(t1); + this.future._completeError$2(t1, stackTrace); + } + }, + $signature: 0 + }; + A.Stream_first_closure0.prototype = { + call$1(value) { + A._cancelAndValue(this.subscription, this.future, A._instanceType(this.$this)._precomputed1._as(value)); + }, + $signature() { + return A._instanceType(this.$this)._eval$1("~(1)"); + } + }; + A._StreamController.prototype = { + get$_pendingEvents() { + var t1, _this = this; + if ((_this._state & 8) === 0) + return A._instanceType(_this)._eval$1("_PendingEvents<1>?")._as(_this._varData); + t1 = A._instanceType(_this); + return t1._eval$1("_PendingEvents<1>?")._as(t1._eval$1("_StreamControllerAddStreamState<1>")._as(_this._varData).get$_varData()); + }, + _ensurePendingEvents$0() { + var events, t1, _this = this; + if ((_this._state & 8) === 0) { + events = _this._varData; + if (events == null) + events = _this._varData = new A._PendingEvents(A._instanceType(_this)._eval$1("_PendingEvents<1>")); + return A._instanceType(_this)._eval$1("_PendingEvents<1>")._as(events); + } + t1 = A._instanceType(_this); + events = t1._eval$1("_StreamControllerAddStreamState<1>")._as(_this._varData).get$_varData(); + return t1._eval$1("_PendingEvents<1>")._as(events); + }, + get$_subscription() { + var varData = this._varData; + if ((this._state & 8) !== 0) + varData = type$._StreamControllerAddStreamState_nullable_Object._as(varData).get$_varData(); + return A._instanceType(this)._eval$1("_ControllerSubscription<1>")._as(varData); + }, + _badEventState$0() { + if ((this._state & 4) !== 0) + return new A.StateError("Cannot add event after closing"); + return new A.StateError("Cannot add event while adding a stream"); + }, + _ensureDoneFuture$0() { + var t1 = this._doneFuture; + if (t1 == null) + t1 = this._doneFuture = (this._state & 2) !== 0 ? $.$get$Future__nullFuture() : new A._Future($.Zone__current, type$._Future_void); + return t1; + }, + add$1(_, value) { + var t2, _this = this, + t1 = A._instanceType(_this); + t1._precomputed1._as(value); + t2 = _this._state; + if (t2 >= 4) + throw A.wrapException(_this._badEventState$0()); + if ((t2 & 1) !== 0) + _this._sendData$1(value); + else if ((t2 & 3) === 0) + _this._ensurePendingEvents$0().add$1(0, new A._DelayedData(value, t1._eval$1("_DelayedData<1>"))); + }, + close$0() { + var _this = this, + t1 = _this._state; + if ((t1 & 4) !== 0) + return _this._ensureDoneFuture$0(); + if (t1 >= 4) + throw A.wrapException(_this._badEventState$0()); + t1 = _this._state = t1 | 4; + if ((t1 & 1) !== 0) + _this._sendDone$0(); + else if ((t1 & 3) === 0) + _this._ensurePendingEvents$0().add$1(0, B.C__DelayedDone); + return _this._ensureDoneFuture$0(); + }, + _subscribe$4(onData, onError, onDone, cancelOnError) { + var t2, t3, t4, t5, t6, t7, t8, subscription, pendingEvents, addState, _this = this, + t1 = A._instanceType(_this); + t1._eval$1("~(1)?")._as(onData); + type$.nullable_void_Function._as(onDone); + if ((_this._state & 3) !== 0) + throw A.wrapException(A.StateError$("Stream has already been listened to.")); + t2 = $.Zone__current; + t3 = cancelOnError ? 1 : 0; + t4 = onError != null ? 32 : 0; + t5 = A._BufferingStreamSubscription__registerDataHandler(t2, onData, t1._precomputed1); + t6 = A._BufferingStreamSubscription__registerErrorHandler(t2, onError); + t7 = onDone == null ? A.async___nullDoneHandler$closure() : onDone; + t8 = type$.void_Function; + subscription = new A._ControllerSubscription(_this, t5, t6, t8._as(t7), t2, t3 | t4, t1._eval$1("_ControllerSubscription<1>")); + pendingEvents = _this.get$_pendingEvents(); + t4 = _this._state |= 1; + if ((t4 & 8) !== 0) { + addState = t1._eval$1("_StreamControllerAddStreamState<1>")._as(_this._varData); + addState.set$_varData(subscription); + addState.resume$0(); + } else + _this._varData = subscription; + subscription._setPendingEvents$1(pendingEvents); + t1 = t8._as(new A._StreamController__subscribe_closure(_this)); + t2 = subscription._state; + subscription._state = t2 | 64; + t1.call$0(); + subscription._state &= 4294967231; + subscription._checkState$1((t2 & 4) !== 0); + return subscription; + }, + _recordCancel$1(subscription) { + var result, onCancel, cancelResult, e, s, exception, result0, _this = this, + t1 = A._instanceType(_this); + t1._eval$1("StreamSubscription<1>")._as(subscription); + result = null; + if ((_this._state & 8) !== 0) + result = t1._eval$1("_StreamControllerAddStreamState<1>")._as(_this._varData).cancel$0(); + _this._varData = null; + _this._state = _this._state & 4294967286 | 2; + onCancel = _this.onCancel; + if (onCancel != null) + if (result == null) + try { + cancelResult = onCancel.call$0(); + if (cancelResult instanceof A._Future) + result = cancelResult; + } catch (exception) { + e = A.unwrapException(exception); + s = A.getTraceFromException(exception); + result0 = new A._Future($.Zone__current, type$._Future_void); + result0._asyncCompleteError$2(e, s); + result = result0; + } + else + result = result.whenComplete$1(onCancel); + t1 = new A._StreamController__recordCancel_complete(_this); + if (result != null) + result = result.whenComplete$1(t1); + else + t1.call$0(); + return result; + }, + $isStreamController: 1, + $is_StreamControllerLifecycle: 1, + $is_EventDispatch: 1 + }; + A._StreamController__subscribe_closure.prototype = { + call$0() { + A._runGuarded(this.$this.onListen); + }, + $signature: 0 + }; + A._StreamController__recordCancel_complete.prototype = { + call$0() { + var doneFuture = this.$this._doneFuture; + if (doneFuture != null && (doneFuture._state & 30) === 0) + doneFuture._asyncComplete$1(null); + }, + $signature: 0 + }; + A._AsyncStreamControllerDispatch.prototype = { + _sendData$1(data) { + var t1 = this.$ti; + t1._precomputed1._as(data); + this.get$_subscription()._addPending$1(new A._DelayedData(data, t1._eval$1("_DelayedData<1>"))); + }, + _sendError$2(error, stackTrace) { + this.get$_subscription()._addPending$1(new A._DelayedError(error, stackTrace)); + }, + _sendDone$0() { + this.get$_subscription()._addPending$1(B.C__DelayedDone); + } + }; + A._AsyncStreamController.prototype = {}; + A._ControllerStream.prototype = { + get$hashCode(_) { + return (A.Primitives_objectHashCode(this._controller) ^ 892482866) >>> 0; + }, + $eq(_, other) { + if (other == null) + return false; + if (this === other) + return true; + return other instanceof A._ControllerStream && other._controller === this._controller; + } + }; + A._ControllerSubscription.prototype = { + _onCancel$0() { + return this._controller._recordCancel$1(this); + }, + _onPause$0() { + var t1 = this._controller, + t2 = A._instanceType(t1); + t2._eval$1("StreamSubscription<1>")._as(this); + if ((t1._state & 8) !== 0) + t2._eval$1("_StreamControllerAddStreamState<1>")._as(t1._varData).pause$0(); + A._runGuarded(t1.onPause); + }, + _onResume$0() { + var t1 = this._controller, + t2 = A._instanceType(t1); + t2._eval$1("StreamSubscription<1>")._as(this); + if ((t1._state & 8) !== 0) + t2._eval$1("_StreamControllerAddStreamState<1>")._as(t1._varData).resume$0(); + A._runGuarded(t1.onResume); + } + }; + A._StreamSinkWrapper.prototype = {}; + A._BufferingStreamSubscription.prototype = { + _setPendingEvents$1(pendingEvents) { + var _this = this; + A._instanceType(_this)._eval$1("_PendingEvents<1>?")._as(pendingEvents); + if (pendingEvents == null) + return; + _this.set$_pending(pendingEvents); + if (pendingEvents.lastPendingEvent != null) { + _this._state |= 128; + pendingEvents.schedule$1(_this); + } + }, + onData$1(handleData) { + var t1 = A._instanceType(this); + this.set$_onData(A._BufferingStreamSubscription__registerDataHandler(this._zone, t1._eval$1("~(1)?")._as(handleData), t1._precomputed1)); + }, + cancel$0() { + var t1 = this._state &= 4294967279; + if ((t1 & 8) === 0) + this._cancel$0(); + t1 = this._cancelFuture; + return t1 == null ? $.$get$Future__nullFuture() : t1; + }, + asFuture$1$1(futureValue, $E) { + var result, _this = this, t1 = {}; + t1.resultValue = null; + if (!$E._is(null)) + throw A.wrapException(A.ArgumentError$notNull("futureValue")); + $E._as(futureValue); + t1.resultValue = futureValue; + result = new A._Future($.Zone__current, $E._eval$1("_Future<0>")); + _this.set$_onDone(new A._BufferingStreamSubscription_asFuture_closure(t1, result)); + _this._state |= 32; + _this._onError = new A._BufferingStreamSubscription_asFuture_closure0(_this, result); + return result; + }, + _cancel$0() { + var t2, _this = this, + t1 = _this._state |= 8; + if ((t1 & 128) !== 0) { + t2 = _this._pending; + if (t2._state === 1) + t2._state = 3; + } + if ((t1 & 64) === 0) + _this.set$_pending(null); + _this._cancelFuture = _this._onCancel$0(); + }, + _onPause$0() { + }, + _onResume$0() { + }, + _onCancel$0() { + return null; + }, + _addPending$1($event) { + var t1, _this = this, + pending = _this._pending; + if (pending == null) { + pending = new A._PendingEvents(A._instanceType(_this)._eval$1("_PendingEvents<1>")); + _this.set$_pending(pending); + } + pending.add$1(0, $event); + t1 = _this._state; + if ((t1 & 128) === 0) { + t1 |= 128; + _this._state = t1; + if (t1 < 256) + pending.schedule$1(_this); + } + }, + _sendData$1(data) { + var t2, _this = this, + t1 = A._instanceType(_this)._precomputed1; + t1._as(data); + t2 = _this._state; + _this._state = t2 | 64; + _this._zone.runUnaryGuarded$1$2(_this._onData, data, t1); + _this._state &= 4294967231; + _this._checkState$1((t2 & 4) !== 0); + }, + _sendError$2(error, stackTrace) { + var cancelFuture, _this = this, + t1 = _this._state, + t2 = new A._BufferingStreamSubscription__sendError_sendError(_this, error, stackTrace); + if ((t1 & 1) !== 0) { + _this._state = t1 | 16; + _this._cancel$0(); + cancelFuture = _this._cancelFuture; + if (cancelFuture != null && cancelFuture !== $.$get$Future__nullFuture()) + cancelFuture.whenComplete$1(t2); + else + t2.call$0(); + } else { + t2.call$0(); + _this._checkState$1((t1 & 4) !== 0); + } + }, + _sendDone$0() { + var cancelFuture, _this = this, + t1 = new A._BufferingStreamSubscription__sendDone_sendDone(_this); + _this._cancel$0(); + _this._state |= 16; + cancelFuture = _this._cancelFuture; + if (cancelFuture != null && cancelFuture !== $.$get$Future__nullFuture()) + cancelFuture.whenComplete$1(t1); + else + t1.call$0(); + }, + _checkState$1(wasInputPaused) { + var t2, isInputPaused, _this = this, + t1 = _this._state; + if ((t1 & 128) !== 0 && _this._pending.lastPendingEvent == null) { + t1 = _this._state = t1 & 4294967167; + if ((t1 & 4) !== 0) + if (t1 < 256) { + t2 = _this._pending; + t2 = t2 == null ? null : t2.lastPendingEvent == null; + t2 = t2 !== false; + } else + t2 = false; + else + t2 = false; + if (t2) { + t1 &= 4294967291; + _this._state = t1; + } + } + for (; true; wasInputPaused = isInputPaused) { + if ((t1 & 8) !== 0) { + _this.set$_pending(null); + return; + } + isInputPaused = (t1 & 4) !== 0; + if (wasInputPaused === isInputPaused) + break; + _this._state = t1 ^ 64; + if (isInputPaused) + _this._onPause$0(); + else + _this._onResume$0(); + t1 = _this._state &= 4294967231; + } + if ((t1 & 128) !== 0 && t1 < 256) + _this._pending.schedule$1(_this); + }, + set$_onData(_onData) { + this._onData = A._instanceType(this)._eval$1("~(1)")._as(_onData); + }, + set$_onDone(_onDone) { + this._onDone = type$.void_Function._as(_onDone); + }, + set$_pending(_pending) { + this._pending = A._instanceType(this)._eval$1("_PendingEvents<1>?")._as(_pending); + }, + $isStreamSubscription: 1, + $is_EventDispatch: 1 + }; + A._BufferingStreamSubscription_asFuture_closure.prototype = { + call$0() { + this.result._complete$1(this._box_0.resultValue); + }, + $signature: 0 + }; + A._BufferingStreamSubscription_asFuture_closure0.prototype = { + call$2(error, stackTrace) { + var cancelFuture = this.$this.cancel$0(), + t1 = this.result; + if (cancelFuture !== $.$get$Future__nullFuture()) + cancelFuture.whenComplete$1(new A._BufferingStreamSubscription_asFuture__closure(t1, error, stackTrace)); + else + t1._completeError$2(error, stackTrace); + }, + $signature: 5 + }; + A._BufferingStreamSubscription_asFuture__closure.prototype = { + call$0() { + this.result._completeError$2(this.error, this.stackTrace); + }, + $signature: 2 + }; + A._BufferingStreamSubscription__sendError_sendError.prototype = { + call$0() { + var onError, t3, t4, + t1 = this.$this, + t2 = t1._state; + if ((t2 & 8) !== 0 && (t2 & 16) === 0) + return; + t1._state = t2 | 64; + onError = t1._onError; + t2 = this.error; + t3 = type$.Object; + t4 = t1._zone; + if (type$.void_Function_Object_StackTrace._is(onError)) + t4.runBinaryGuarded$2$3(onError, t2, this.stackTrace, t3, type$.StackTrace); + else + t4.runUnaryGuarded$1$2(type$.void_Function_Object._as(onError), t2, t3); + t1._state &= 4294967231; + }, + $signature: 0 + }; + A._BufferingStreamSubscription__sendDone_sendDone.prototype = { + call$0() { + var t1 = this.$this, + t2 = t1._state; + if ((t2 & 16) === 0) + return; + t1._state = t2 | 74; + t1._zone.runGuarded$1(t1._onDone); + t1._state &= 4294967231; + }, + $signature: 0 + }; + A._StreamImpl.prototype = { + listen$4$cancelOnError$onDone$onError(onData, cancelOnError, onDone, onError) { + var t1 = this.$ti; + t1._eval$1("~(1)?")._as(onData); + type$.nullable_void_Function._as(onDone); + return this._controller._subscribe$4(t1._eval$1("~(1)?")._as(onData), onError, onDone, cancelOnError === true); + }, + listen$1(onData) { + return this.listen$4$cancelOnError$onDone$onError(onData, null, null, null); + }, + listen$2$cancelOnError(onData, cancelOnError) { + return this.listen$4$cancelOnError$onDone$onError(onData, cancelOnError, null, null); + }, + listen$2$onDone(onData, onDone) { + return this.listen$4$cancelOnError$onDone$onError(onData, null, onDone, null); + } + }; + A._DelayedEvent.prototype = { + set$next(next) { + this.next = type$.nullable__DelayedEvent_dynamic._as(next); + }, + get$next() { + return this.next; + } + }; + A._DelayedData.prototype = { + perform$1(dispatch) { + this.$ti._eval$1("_EventDispatch<1>")._as(dispatch)._sendData$1(this.value); + } + }; + A._DelayedError.prototype = { + perform$1(dispatch) { + dispatch._sendError$2(this.error, this.stackTrace); + } + }; + A._DelayedDone.prototype = { + perform$1(dispatch) { + dispatch._sendDone$0(); + }, + get$next() { + return null; + }, + set$next(_) { + throw A.wrapException(A.StateError$("No events after a done.")); + }, + $is_DelayedEvent: 1 + }; + A._PendingEvents.prototype = { + schedule$1(dispatch) { + var t1, _this = this; + _this.$ti._eval$1("_EventDispatch<1>")._as(dispatch); + t1 = _this._state; + if (t1 === 1) + return; + if (t1 >= 1) { + _this._state = 1; + return; + } + A.scheduleMicrotask(new A._PendingEvents_schedule_closure(_this, dispatch)); + _this._state = 1; + }, + add$1(_, $event) { + var _this = this, + lastEvent = _this.lastPendingEvent; + if (lastEvent == null) + _this.firstPendingEvent = _this.lastPendingEvent = $event; + else { + lastEvent.set$next($event); + _this.lastPendingEvent = $event; + } + } + }; + A._PendingEvents_schedule_closure.prototype = { + call$0() { + var t2, $event, nextEvent, + t1 = this.$this, + oldState = t1._state; + t1._state = 0; + if (oldState === 3) + return; + t2 = t1.$ti._eval$1("_EventDispatch<1>")._as(this.dispatch); + $event = t1.firstPendingEvent; + nextEvent = $event.get$next(); + t1.firstPendingEvent = nextEvent; + if (nextEvent == null) + t1.lastPendingEvent = null; + $event.perform$1(t2); + }, + $signature: 0 + }; + A._StreamIterator.prototype = {}; + A._cancelAndValue_closure.prototype = { + call$0() { + return this.future._complete$1(this.value); + }, + $signature: 0 + }; + A._Zone.prototype = {$isZone: 1}; + A._rootHandleError_closure.prototype = { + call$0() { + A.Error_throwWithStackTrace(this.error, this.stackTrace); + }, + $signature: 0 + }; + A._RootZone.prototype = { + runGuarded$1(f) { + var e, s, exception; + type$.void_Function._as(f); + try { + if (B.C__RootZone === $.Zone__current) { + f.call$0(); + return; + } + A._rootRun(null, null, this, f, type$.void); + } catch (exception) { + e = A.unwrapException(exception); + s = A.getTraceFromException(exception); + A._rootHandleError(type$.Object._as(e), type$.StackTrace._as(s)); + } + }, + runUnaryGuarded$1$2(f, arg, $T) { + var e, s, exception; + $T._eval$1("~(0)")._as(f); + $T._as(arg); + try { + if (B.C__RootZone === $.Zone__current) { + f.call$1(arg); + return; + } + A._rootRunUnary(null, null, this, f, arg, type$.void, $T); + } catch (exception) { + e = A.unwrapException(exception); + s = A.getTraceFromException(exception); + A._rootHandleError(type$.Object._as(e), type$.StackTrace._as(s)); + } + }, + runBinaryGuarded$2$3(f, arg1, arg2, T1, T2) { + var e, s, exception; + T1._eval$1("@<0>")._bind$1(T2)._eval$1("~(1,2)")._as(f); + T1._as(arg1); + T2._as(arg2); + try { + if (B.C__RootZone === $.Zone__current) { + f.call$2(arg1, arg2); + return; + } + A._rootRunBinary(null, null, this, f, arg1, arg2, type$.void, T1, T2); + } catch (exception) { + e = A.unwrapException(exception); + s = A.getTraceFromException(exception); + A._rootHandleError(type$.Object._as(e), type$.StackTrace._as(s)); + } + }, + bindCallbackGuarded$1(f) { + return new A._RootZone_bindCallbackGuarded_closure(this, type$.void_Function._as(f)); + }, + bindUnaryCallbackGuarded$1$1(f, $T) { + return new A._RootZone_bindUnaryCallbackGuarded_closure(this, $T._eval$1("~(0)")._as(f), $T); + }, + run$1$1(f, $R) { + $R._eval$1("0()")._as(f); + if ($.Zone__current === B.C__RootZone) + return f.call$0(); + return A._rootRun(null, null, this, f, $R); + }, + runUnary$2$2(f, arg, $R, $T) { + $R._eval$1("@<0>")._bind$1($T)._eval$1("1(2)")._as(f); + $T._as(arg); + if ($.Zone__current === B.C__RootZone) + return f.call$1(arg); + return A._rootRunUnary(null, null, this, f, arg, $R, $T); + }, + runBinary$3$3(f, arg1, arg2, $R, T1, T2) { + $R._eval$1("@<0>")._bind$1(T1)._bind$1(T2)._eval$1("1(2,3)")._as(f); + T1._as(arg1); + T2._as(arg2); + if ($.Zone__current === B.C__RootZone) + return f.call$2(arg1, arg2); + return A._rootRunBinary(null, null, this, f, arg1, arg2, $R, T1, T2); + }, + registerBinaryCallback$3$1(f, $R, T1, T2) { + return $R._eval$1("@<0>")._bind$1(T1)._bind$1(T2)._eval$1("1(2,3)")._as(f); + } + }; + A._RootZone_bindCallbackGuarded_closure.prototype = { + call$0() { + return this.$this.runGuarded$1(this.f); + }, + $signature: 0 + }; + A._RootZone_bindUnaryCallbackGuarded_closure.prototype = { + call$1(arg) { + var t1 = this.T; + return this.$this.runUnaryGuarded$1$2(this.f, t1._as(arg), t1); + }, + $signature() { + return this.T._eval$1("~(0)"); + } + }; + A._HashMap.prototype = { + get$length(_) { + return this._collection$_length; + }, + get$isEmpty(_) { + return this._collection$_length === 0; + }, + get$keys() { + return new A._HashMapKeyIterable(this, this.$ti._eval$1("_HashMapKeyIterable<1>")); + }, + containsKey$1(key) { + var strings, nums; + if (typeof key == "string" && key !== "__proto__") { + strings = this._collection$_strings; + return strings == null ? false : strings[key] != null; + } else if (typeof key == "number" && (key & 1073741823) === key) { + nums = this._collection$_nums; + return nums == null ? false : nums[key] != null; + } else + return this._containsKey$1(key); + }, + _containsKey$1(key) { + var rest = this._collection$_rest; + if (rest == null) + return false; + return this._findBucketIndex$2(this._getBucket$2(rest, key), key) >= 0; + }, + $index(_, key) { + var strings, t1, nums; + if (typeof key == "string" && key !== "__proto__") { + strings = this._collection$_strings; + t1 = strings == null ? null : A._HashMap__getTableEntry(strings, key); + return t1; + } else if (typeof key == "number" && (key & 1073741823) === key) { + nums = this._collection$_nums; + t1 = nums == null ? null : A._HashMap__getTableEntry(nums, key); + return t1; + } else + return this._get$1(key); + }, + _get$1(key) { + var bucket, index, + rest = this._collection$_rest; + if (rest == null) + return null; + bucket = this._getBucket$2(rest, key); + index = this._findBucketIndex$2(bucket, key); + return index < 0 ? null : bucket[index + 1]; + }, + $indexSet(_, key, value) { + var strings, nums, rest, hash, bucket, index, _this = this, + t1 = _this.$ti; + t1._precomputed1._as(key); + t1._rest[1]._as(value); + if (typeof key == "string" && key !== "__proto__") { + strings = _this._collection$_strings; + _this._collection$_addHashTableEntry$3(strings == null ? _this._collection$_strings = A._HashMap__newHashTable() : strings, key, value); + } else if (typeof key == "number" && (key & 1073741823) === key) { + nums = _this._collection$_nums; + _this._collection$_addHashTableEntry$3(nums == null ? _this._collection$_nums = A._HashMap__newHashTable() : nums, key, value); + } else { + rest = _this._collection$_rest; + if (rest == null) + rest = _this._collection$_rest = A._HashMap__newHashTable(); + hash = A.objectHashCode(key) & 1073741823; + bucket = rest[hash]; + if (bucket == null) { + A._HashMap__setTableEntry(rest, hash, [key, value]); + ++_this._collection$_length; + _this._collection$_keys = null; + } else { + index = _this._findBucketIndex$2(bucket, key); + if (index >= 0) + bucket[index + 1] = value; + else { + bucket.push(key, value); + ++_this._collection$_length; + _this._collection$_keys = null; + } + } + } + }, + forEach$1(_, action) { + var keys, $length, t2, i, key, t3, _this = this, + t1 = _this.$ti; + t1._eval$1("~(1,2)")._as(action); + keys = _this._computeKeys$0(); + for ($length = keys.length, t2 = t1._precomputed1, t1 = t1._rest[1], i = 0; i < $length; ++i) { + key = keys[i]; + t2._as(key); + t3 = _this.$index(0, key); + action.call$2(key, t3 == null ? t1._as(t3) : t3); + if (keys !== _this._collection$_keys) + throw A.wrapException(A.ConcurrentModificationError$(_this)); + } + }, + _computeKeys$0() { + var strings, names, entries, index, i, nums, rest, bucket, $length, i0, _this = this, + result = _this._collection$_keys; + if (result != null) + return result; + result = A.List_List$filled(_this._collection$_length, null, false, type$.dynamic); + strings = _this._collection$_strings; + if (strings != null) { + names = Object.getOwnPropertyNames(strings); + entries = names.length; + for (index = 0, i = 0; i < entries; ++i) { + result[index] = names[i]; + ++index; + } + } else + index = 0; + nums = _this._collection$_nums; + if (nums != null) { + names = Object.getOwnPropertyNames(nums); + entries = names.length; + for (i = 0; i < entries; ++i) { + result[index] = +names[i]; + ++index; + } + } + rest = _this._collection$_rest; + if (rest != null) { + names = Object.getOwnPropertyNames(rest); + entries = names.length; + for (i = 0; i < entries; ++i) { + bucket = rest[names[i]]; + $length = bucket.length; + for (i0 = 0; i0 < $length; i0 += 2) { + result[index] = bucket[i0]; + ++index; + } + } + } + return _this._collection$_keys = result; + }, + _collection$_addHashTableEntry$3(table, key, value) { + var t1 = this.$ti; + t1._precomputed1._as(key); + t1._rest[1]._as(value); + if (table[key] == null) { + ++this._collection$_length; + this._collection$_keys = null; + } + A._HashMap__setTableEntry(table, key, value); + }, + _getBucket$2(table, key) { + return table[A.objectHashCode(key) & 1073741823]; + } + }; + A._IdentityHashMap.prototype = { + _findBucketIndex$2(bucket, key) { + var $length, i, t1; + if (bucket == null) + return -1; + $length = bucket.length; + for (i = 0; i < $length; i += 2) { + t1 = bucket[i]; + if (t1 == null ? key == null : t1 === key) + return i; + } + return -1; + } + }; + A._HashMapKeyIterable.prototype = { + get$length(_) { + return this._collection$_map._collection$_length; + }, + get$isEmpty(_) { + return this._collection$_map._collection$_length === 0; + }, + get$iterator(_) { + var t1 = this._collection$_map; + return new A._HashMapKeyIterator(t1, t1._computeKeys$0(), this.$ti._eval$1("_HashMapKeyIterator<1>")); + } + }; + A._HashMapKeyIterator.prototype = { + get$current() { + var t1 = this._collection$_current; + return t1 == null ? this.$ti._precomputed1._as(t1) : t1; + }, + moveNext$0() { + var _this = this, + keys = _this._collection$_keys, + offset = _this._offset, + t1 = _this._collection$_map; + if (keys !== t1._collection$_keys) + throw A.wrapException(A.ConcurrentModificationError$(t1)); + else if (offset >= keys.length) { + _this.set$_collection$_current(null); + return false; + } else { + _this.set$_collection$_current(keys[offset]); + _this._offset = offset + 1; + return true; + } + }, + set$_collection$_current(_current) { + this._collection$_current = this.$ti._eval$1("1?")._as(_current); + } + }; + A.ListBase.prototype = { + get$iterator(receiver) { + return new A.ListIterator(receiver, this.get$length(receiver), A.instanceType(receiver)._eval$1("ListIterator")); + }, + elementAt$1(receiver, index) { + return this.$index(receiver, index); + }, + get$isNotEmpty(receiver) { + return this.get$length(receiver) !== 0; + }, + toString$0(receiver) { + return A.Iterable_iterableToFullString(receiver, "[", "]"); + } + }; + A.MapBase.prototype = { + forEach$1(_, action) { + var t2, key, t3, + t1 = A._instanceType(this); + t1._eval$1("~(MapBase.K,MapBase.V)")._as(action); + for (t2 = this.get$keys(), t2 = t2.get$iterator(t2), t1 = t1._eval$1("MapBase.V"); t2.moveNext$0();) { + key = t2.get$current(); + t3 = this.$index(0, key); + action.call$2(key, t3 == null ? t1._as(t3) : t3); + } + }, + get$length(_) { + var t1 = this.get$keys(); + return t1.get$length(t1); + }, + get$isEmpty(_) { + var t1 = this.get$keys(); + return t1.get$isEmpty(t1); + }, + toString$0(_) { + return A.MapBase_mapToString(this); + }, + $isMap: 1 + }; + A.MapBase_mapToString_closure.prototype = { + call$2(k, v) { + var t2, + t1 = this._box_0; + if (!t1.first) + this.result._contents += ", "; + t1.first = false; + t1 = this.result; + t2 = A.S(k); + t2 = t1._contents += t2; + t1._contents = t2 + ": "; + t2 = A.S(v); + t1._contents += t2; + }, + $signature: 10 + }; + A._UnmodifiableMapMixin.prototype = {}; + A.MapView.prototype = { + forEach$1(_, action) { + this._collection$_map.forEach$1(0, A._instanceType(this)._eval$1("~(1,2)")._as(action)); + }, + get$isEmpty(_) { + return this._collection$_map.__js_helper$_length === 0; + }, + get$length(_) { + return this._collection$_map.__js_helper$_length; + }, + toString$0(_) { + return A.MapBase_mapToString(this._collection$_map); + }, + $isMap: 1 + }; + A.UnmodifiableMapView.prototype = {}; + A.ListQueue.prototype = { + get$iterator(_) { + var _this = this; + return new A._ListQueueIterator(_this, _this._tail, _this._modificationCount, _this._head, _this.$ti._eval$1("_ListQueueIterator<1>")); + }, + get$isEmpty(_) { + return this._head === this._tail; + }, + get$length(_) { + return (this._tail - this._head & this._table.length - 1) >>> 0; + }, + elementAt$1(_, index) { + var t2, t3, _this = this, + t1 = _this.get$length(0); + if (0 > index || index >= t1) + A.throwExpression(A.IndexError$withLength(index, t1, _this, null, "index")); + t1 = _this._table; + t2 = t1.length; + t3 = (_this._head + index & t2 - 1) >>> 0; + if (!(t3 >= 0 && t3 < t2)) + return A.ioore(t1, t3); + t3 = t1[t3]; + return t3 == null ? _this.$ti._precomputed1._as(t3) : t3; + }, + toString$0(_) { + return A.Iterable_iterableToFullString(this, "{", "}"); + }, + removeFirst$0() { + var t2, result, _this = this, + t1 = _this._head; + if (t1 === _this._tail) + throw A.wrapException(A.IterableElementError_noElement()); + ++_this._modificationCount; + t2 = _this._table; + if (!(t1 < t2.length)) + return A.ioore(t2, t1); + result = t2[t1]; + if (result == null) + result = _this.$ti._precomputed1._as(result); + B.JSArray_methods.$indexSet(t2, t1, null); + _this._head = (_this._head + 1 & _this._table.length - 1) >>> 0; + return result; + }, + _add$1(element) { + var t2, t3, newTable, split, _this = this, + t1 = _this.$ti; + t1._precomputed1._as(element); + B.JSArray_methods.$indexSet(_this._table, _this._tail, element); + t2 = _this._tail; + t3 = _this._table.length; + t2 = (t2 + 1 & t3 - 1) >>> 0; + _this._tail = t2; + if (_this._head === t2) { + newTable = A.List_List$filled(t3 * 2, null, false, t1._eval$1("1?")); + t1 = _this._table; + t2 = _this._head; + split = t1.length - t2; + B.JSArray_methods.setRange$4(newTable, 0, split, t1, t2); + B.JSArray_methods.setRange$4(newTable, split, split + _this._head, _this._table, 0); + _this._head = 0; + _this._tail = _this._table.length; + _this.set$_table(newTable); + } + ++_this._modificationCount; + }, + set$_table(_table) { + this._table = this.$ti._eval$1("List<1?>")._as(_table); + }, + $isQueue: 1 + }; + A._ListQueueIterator.prototype = { + get$current() { + var t1 = this._collection$_current; + return t1 == null ? this.$ti._precomputed1._as(t1) : t1; + }, + moveNext$0() { + var t2, t3, _this = this, + t1 = _this._queue; + if (_this._modificationCount !== t1._modificationCount) + A.throwExpression(A.ConcurrentModificationError$(t1)); + t2 = _this._position; + if (t2 === _this._end) { + _this.set$_collection$_current(null); + return false; + } + t3 = t1._table; + if (!(t2 < t3.length)) + return A.ioore(t3, t2); + _this.set$_collection$_current(t3[t2]); + _this._position = (_this._position + 1 & t1._table.length - 1) >>> 0; + return true; + }, + set$_collection$_current(_current) { + this._collection$_current = this.$ti._eval$1("1?")._as(_current); + } + }; + A._UnmodifiableMapView_MapView__UnmodifiableMapMixin.prototype = {}; + A._JsonMap.prototype = { + $index(_, key) { + var result, + t1 = this._processed; + if (t1 == null) + return this._data.$index(0, key); + else if (typeof key != "string") + return null; + else { + result = t1[key]; + return typeof result == "undefined" ? this._process$1(key) : result; + } + }, + get$length(_) { + return this._processed == null ? this._data.__js_helper$_length : this._convert$_computeKeys$0().length; + }, + get$isEmpty(_) { + return this.get$length(0) === 0; + }, + get$keys() { + if (this._processed == null) { + var t1 = this._data; + return new A.LinkedHashMapKeyIterable(t1, A._instanceType(t1)._eval$1("LinkedHashMapKeyIterable<1>")); + } + return new A._JsonMapKeyIterable(this); + }, + forEach$1(_, f) { + var keys, i, key, value, _this = this; + type$.void_Function_String_dynamic._as(f); + if (_this._processed == null) + return _this._data.forEach$1(0, f); + keys = _this._convert$_computeKeys$0(); + for (i = 0; i < keys.length; ++i) { + key = keys[i]; + value = _this._processed[key]; + if (typeof value == "undefined") { + value = A._convertJsonToDartLazy(_this._original[key]); + _this._processed[key] = value; + } + f.call$2(key, value); + if (keys !== _this._data) + throw A.wrapException(A.ConcurrentModificationError$(_this)); + } + }, + _convert$_computeKeys$0() { + var keys = type$.nullable_List_dynamic._as(this._data); + if (keys == null) + keys = this._data = A._setArrayType(Object.keys(this._original), type$.JSArray_String); + return keys; + }, + _process$1(key) { + var result; + if (!Object.prototype.hasOwnProperty.call(this._original, key)) + return null; + result = A._convertJsonToDartLazy(this._original[key]); + return this._processed[key] = result; + } + }; + A._JsonMapKeyIterable.prototype = { + get$length(_) { + return this._parent.get$length(0); + }, + elementAt$1(_, index) { + var t1 = this._parent; + if (t1._processed == null) + t1 = t1.get$keys().elementAt$1(0, index); + else { + t1 = t1._convert$_computeKeys$0(); + if (!(index >= 0 && index < t1.length)) + return A.ioore(t1, index); + t1 = t1[index]; + } + return t1; + }, + get$iterator(_) { + var t1 = this._parent; + if (t1._processed == null) { + t1 = t1.get$keys(); + t1 = t1.get$iterator(t1); + } else { + t1 = t1._convert$_computeKeys$0(); + t1 = new J.ArrayIterator(t1, t1.length, A._arrayInstanceType(t1)._eval$1("ArrayIterator<1>")); + } + return t1; + } + }; + A.Codec.prototype = {}; + A.Converter.prototype = {}; + A.JsonUnsupportedObjectError.prototype = { + toString$0(_) { + var safeString = A.Error_safeToString(this.unsupportedObject); + return (this.cause != null ? "Converting object to an encodable object failed:" : "Converting object did not return an encodable object:") + " " + safeString; + } + }; + A.JsonCyclicError.prototype = { + toString$0(_) { + return "Cyclic error in JSON stringify"; + } + }; + A.JsonCodec.prototype = { + decode$2$reviver(source, reviver) { + var t1 = A._parseJson(source, this.get$decoder()._reviver); + return t1; + }, + encode$2$toEncodable(value, toEncodable) { + var t1 = A._JsonStringStringifier_stringify(value, this.get$encoder()._toEncodable, null); + return t1; + }, + get$encoder() { + return B.JsonEncoder_null; + }, + get$decoder() { + return B.JsonDecoder_null; + } + }; + A.JsonEncoder.prototype = {}; + A.JsonDecoder.prototype = {}; + A._JsonStringifier.prototype = { + writeStringContent$1(s) { + var offset, i, charCode, t1, t2, _this = this, + $length = s.length; + for (offset = 0, i = 0; i < $length; ++i) { + charCode = s.charCodeAt(i); + if (charCode > 92) { + if (charCode >= 55296) { + t1 = charCode & 64512; + if (t1 === 55296) { + t2 = i + 1; + t2 = !(t2 < $length && (s.charCodeAt(t2) & 64512) === 56320); + } else + t2 = false; + if (!t2) + if (t1 === 56320) { + t1 = i - 1; + t1 = !(t1 >= 0 && (s.charCodeAt(t1) & 64512) === 55296); + } else + t1 = false; + else + t1 = true; + if (t1) { + if (i > offset) + _this.writeStringSlice$3(s, offset, i); + offset = i + 1; + _this.writeCharCode$1(92); + _this.writeCharCode$1(117); + _this.writeCharCode$1(100); + t1 = charCode >>> 8 & 15; + _this.writeCharCode$1(t1 < 10 ? 48 + t1 : 87 + t1); + t1 = charCode >>> 4 & 15; + _this.writeCharCode$1(t1 < 10 ? 48 + t1 : 87 + t1); + t1 = charCode & 15; + _this.writeCharCode$1(t1 < 10 ? 48 + t1 : 87 + t1); + } + } + continue; + } + if (charCode < 32) { + if (i > offset) + _this.writeStringSlice$3(s, offset, i); + offset = i + 1; + _this.writeCharCode$1(92); + switch (charCode) { + case 8: + _this.writeCharCode$1(98); + break; + case 9: + _this.writeCharCode$1(116); + break; + case 10: + _this.writeCharCode$1(110); + break; + case 12: + _this.writeCharCode$1(102); + break; + case 13: + _this.writeCharCode$1(114); + break; + default: + _this.writeCharCode$1(117); + _this.writeCharCode$1(48); + _this.writeCharCode$1(48); + t1 = charCode >>> 4 & 15; + _this.writeCharCode$1(t1 < 10 ? 48 + t1 : 87 + t1); + t1 = charCode & 15; + _this.writeCharCode$1(t1 < 10 ? 48 + t1 : 87 + t1); + break; + } + } else if (charCode === 34 || charCode === 92) { + if (i > offset) + _this.writeStringSlice$3(s, offset, i); + offset = i + 1; + _this.writeCharCode$1(92); + _this.writeCharCode$1(charCode); + } + } + if (offset === 0) + _this.writeString$1(s); + else if (offset < $length) + _this.writeStringSlice$3(s, offset, $length); + }, + _checkCycle$1(object) { + var t1, t2, i, t3; + for (t1 = this._seen, t2 = t1.length, i = 0; i < t2; ++i) { + t3 = t1[i]; + if (object == null ? t3 == null : object === t3) + throw A.wrapException(new A.JsonCyclicError(object, null)); + } + B.JSArray_methods.add$1(t1, object); + }, + writeObject$1(object) { + var customJson, e, t1, exception, _this = this; + if (_this.writeJsonValue$1(object)) + return; + _this._checkCycle$1(object); + try { + customJson = _this._toEncodable.call$1(object); + if (!_this.writeJsonValue$1(customJson)) { + t1 = A.JsonUnsupportedObjectError$(object, null, _this.get$_partialResult()); + throw A.wrapException(t1); + } + t1 = _this._seen; + if (0 >= t1.length) + return A.ioore(t1, -1); + t1.pop(); + } catch (exception) { + e = A.unwrapException(exception); + t1 = A.JsonUnsupportedObjectError$(object, e, _this.get$_partialResult()); + throw A.wrapException(t1); + } + }, + writeJsonValue$1(object) { + var t1, success, _this = this; + if (typeof object == "number") { + if (!isFinite(object)) + return false; + _this.writeNumber$1(object); + return true; + } else if (object === true) { + _this.writeString$1("true"); + return true; + } else if (object === false) { + _this.writeString$1("false"); + return true; + } else if (object == null) { + _this.writeString$1("null"); + return true; + } else if (typeof object == "string") { + _this.writeString$1('"'); + _this.writeStringContent$1(object); + _this.writeString$1('"'); + return true; + } else if (type$.List_dynamic._is(object)) { + _this._checkCycle$1(object); + _this.writeList$1(object); + t1 = _this._seen; + if (0 >= t1.length) + return A.ioore(t1, -1); + t1.pop(); + return true; + } else if (type$.Map_dynamic_dynamic._is(object)) { + _this._checkCycle$1(object); + success = _this.writeMap$1(object); + t1 = _this._seen; + if (0 >= t1.length) + return A.ioore(t1, -1); + t1.pop(); + return success; + } else + return false; + }, + writeList$1(list) { + var t1, i, _this = this; + _this.writeString$1("["); + t1 = J.getInterceptor$asx(list); + if (t1.get$isNotEmpty(list)) { + _this.writeObject$1(t1.$index(list, 0)); + for (i = 1; i < t1.get$length(list); ++i) { + _this.writeString$1(","); + _this.writeObject$1(t1.$index(list, i)); + } + } + _this.writeString$1("]"); + }, + writeMap$1(map) { + var t1, keyValueList, i, separator, t2, _this = this, _box_0 = {}; + if (map.get$isEmpty(map)) { + _this.writeString$1("{}"); + return true; + } + t1 = map.get$length(map) * 2; + keyValueList = A.List_List$filled(t1, null, false, type$.nullable_Object); + i = _box_0.i = 0; + _box_0.allStringKeys = true; + map.forEach$1(0, new A._JsonStringifier_writeMap_closure(_box_0, keyValueList)); + if (!_box_0.allStringKeys) + return false; + _this.writeString$1("{"); + for (separator = '"'; i < t1; i += 2, separator = ',"') { + _this.writeString$1(separator); + _this.writeStringContent$1(A._asString(keyValueList[i])); + _this.writeString$1('":'); + t2 = i + 1; + if (!(t2 < t1)) + return A.ioore(keyValueList, t2); + _this.writeObject$1(keyValueList[t2]); + } + _this.writeString$1("}"); + return true; + } + }; + A._JsonStringifier_writeMap_closure.prototype = { + call$2(key, value) { + var t1, t2; + if (typeof key != "string") + this._box_0.allStringKeys = false; + t1 = this.keyValueList; + t2 = this._box_0; + B.JSArray_methods.$indexSet(t1, t2.i++, key); + B.JSArray_methods.$indexSet(t1, t2.i++, value); + }, + $signature: 10 + }; + A._JsonStringStringifier.prototype = { + get$_partialResult() { + var t1 = this._sink._contents; + return t1.charCodeAt(0) == 0 ? t1 : t1; + }, + writeNumber$1(number) { + var t1 = this._sink, + t2 = B.JSNumber_methods.toString$0(number); + t1._contents += t2; + }, + writeString$1(string) { + this._sink._contents += string; + }, + writeStringSlice$3(string, start, end) { + this._sink._contents += B.JSString_methods.substring$2(string, start, end); + }, + writeCharCode$1(charCode) { + var t1 = this._sink, + t2 = A.Primitives_stringFromCharCode(charCode); + t1._contents += t2; + } + }; + A.NoSuchMethodError_toString_closure.prototype = { + call$2(key, value) { + var t1, t2, t3; + type$.Symbol._as(key); + t1 = this.sb; + t2 = this._box_0; + t3 = t1._contents += t2.comma; + t3 += key._name; + t1._contents = t3; + t1._contents = t3 + ": "; + t3 = A.Error_safeToString(value); + t1._contents += t3; + t2.comma = ", "; + }, + $signature: 19 + }; + A.DateTime.prototype = { + $eq(_, other) { + if (other == null) + return false; + return other instanceof A.DateTime && this._value === other._value && this.isUtc === other.isUtc; + }, + get$hashCode(_) { + var t1 = this._value; + return (t1 ^ B.JSInt_methods._shrOtherPositive$1(t1, 30)) & 1073741823; + }, + toString$0(_) { + var _this = this, + y = A.DateTime__fourDigits(A.Primitives_getYear(_this)), + m = A.DateTime__twoDigits(A.Primitives_getMonth(_this)), + d = A.DateTime__twoDigits(A.Primitives_getDay(_this)), + h = A.DateTime__twoDigits(A.Primitives_getHours(_this)), + min = A.DateTime__twoDigits(A.Primitives_getMinutes(_this)), + sec = A.DateTime__twoDigits(A.Primitives_getSeconds(_this)), + ms = A.DateTime__threeDigits(A.Primitives_getMilliseconds(_this)), + t1 = y + "-" + m; + if (_this.isUtc) + return t1 + "-" + d + " " + h + ":" + min + ":" + sec + "." + ms + "Z"; + else + return t1 + "-" + d + " " + h + ":" + min + ":" + sec + "." + ms; + } + }; + A.Duration.prototype = { + $eq(_, other) { + if (other == null) + return false; + return other instanceof A.Duration && this._duration === other._duration; + }, + get$hashCode(_) { + return B.JSInt_methods.get$hashCode(this._duration); + }, + toString$0(_) { + var minutesPadding, seconds, secondsPadding, + microseconds = this._duration, + microseconds0 = microseconds % 3600000000, + minutes = B.JSInt_methods._tdivFast$1(microseconds0, 60000000); + microseconds0 %= 60000000; + minutesPadding = minutes < 10 ? "0" : ""; + seconds = B.JSInt_methods._tdivFast$1(microseconds0, 1000000); + secondsPadding = seconds < 10 ? "0" : ""; + return "" + (microseconds / 3600000000 | 0) + ":" + minutesPadding + minutes + ":" + secondsPadding + seconds + "." + B.JSString_methods.padLeft$2(B.JSInt_methods.toString$0(microseconds0 % 1000000), 6, "0"); + } + }; + A.Error.prototype = { + get$stackTrace() { + return A.getTraceFromException(this.$thrownJsError); + } + }; + A.AssertionError.prototype = { + toString$0(_) { + var t1 = this.message; + if (t1 != null) + return "Assertion failed: " + A.Error_safeToString(t1); + return "Assertion failed"; + } + }; + A.TypeError.prototype = {}; + A.ArgumentError.prototype = { + get$_errorName() { + return "Invalid argument" + (!this._hasValue ? "(s)" : ""); + }, + get$_errorExplanation() { + return ""; + }, + toString$0(_) { + var _this = this, + $name = _this.name, + nameString = $name == null ? "" : " (" + $name + ")", + message = _this.message, + messageString = message == null ? "" : ": " + A.S(message), + prefix = _this.get$_errorName() + nameString + messageString; + if (!_this._hasValue) + return prefix; + return prefix + _this.get$_errorExplanation() + ": " + A.Error_safeToString(_this.get$invalidValue()); + }, + get$invalidValue() { + return this.invalidValue; + } + }; + A.RangeError.prototype = { + get$invalidValue() { + return A._asNumQ(this.invalidValue); + }, + get$_errorName() { + return "RangeError"; + }, + get$_errorExplanation() { + var explanation, + start = this.start, + end = this.end; + if (start == null) + explanation = end != null ? ": Not less than or equal to " + A.S(end) : ""; + else if (end == null) + explanation = ": Not greater than or equal to " + A.S(start); + else if (end > start) + explanation = ": Not in inclusive range " + A.S(start) + ".." + A.S(end); + else + explanation = end < start ? ": Valid value range is empty" : ": Only valid value is " + A.S(start); + return explanation; + } + }; + A.IndexError.prototype = { + get$invalidValue() { + return A._asInt(this.invalidValue); + }, + get$_errorName() { + return "RangeError"; + }, + get$_errorExplanation() { + if (A._asInt(this.invalidValue) < 0) + return ": index must not be negative"; + var t1 = this.length; + if (t1 === 0) + return ": no indices are valid"; + return ": index should be less than " + t1; + }, + get$length(receiver) { + return this.length; + } + }; + A.NoSuchMethodError.prototype = { + toString$0(_) { + var $arguments, t1, _i, t2, t3, argument, receiverText, actualParameters, _this = this, _box_0 = {}, + sb = new A.StringBuffer(""); + _box_0.comma = ""; + $arguments = _this._core$_arguments; + for (t1 = $arguments.length, _i = 0, t2 = "", t3 = ""; _i < t1; ++_i, t3 = ", ") { + argument = $arguments[_i]; + sb._contents = t2 + t3; + t2 = A.Error_safeToString(argument); + t2 = sb._contents += t2; + _box_0.comma = ", "; + } + _this._namedArguments.forEach$1(0, new A.NoSuchMethodError_toString_closure(_box_0, sb)); + receiverText = A.Error_safeToString(_this._core$_receiver); + actualParameters = sb.toString$0(0); + return "NoSuchMethodError: method not found: '" + _this._core$_memberName._name + "'\nReceiver: " + receiverText + "\nArguments: [" + actualParameters + "]"; + } + }; + A.UnsupportedError.prototype = { + toString$0(_) { + return "Unsupported operation: " + this.message; + } + }; + A.UnimplementedError.prototype = { + toString$0(_) { + return "UnimplementedError: " + this.message; + } + }; + A.StateError.prototype = { + toString$0(_) { + return "Bad state: " + this.message; + } + }; + A.ConcurrentModificationError.prototype = { + toString$0(_) { + var t1 = this.modifiedObject; + if (t1 == null) + return "Concurrent modification during iteration."; + return "Concurrent modification during iteration: " + A.Error_safeToString(t1) + "."; + } + }; + A.OutOfMemoryError.prototype = { + toString$0(_) { + return "Out of Memory"; + }, + get$stackTrace() { + return null; + }, + $isError: 1 + }; + A.StackOverflowError.prototype = { + toString$0(_) { + return "Stack Overflow"; + }, + get$stackTrace() { + return null; + }, + $isError: 1 + }; + A._Exception.prototype = { + toString$0(_) { + return "Exception: " + this.message; + } + }; + A.FormatException.prototype = { + toString$0(_) { + var t1, lineEnd, lineNum, lineStart, previousCharWasCR, i, char, end, start, prefix, postfix, + message = this.message, + report = "" !== message ? "FormatException: " + message : "FormatException", + offset = this.offset, + source = this.source; + if (typeof source == "string") { + if (offset != null) + t1 = offset < 0 || offset > source.length; + else + t1 = false; + if (t1) + offset = null; + if (offset == null) { + if (source.length > 78) + source = B.JSString_methods.substring$2(source, 0, 75) + "..."; + return report + "\n" + source; + } + for (lineEnd = source.length, lineNum = 1, lineStart = 0, previousCharWasCR = false, i = 0; i < offset; ++i) { + if (!(i < lineEnd)) + return A.ioore(source, i); + char = source.charCodeAt(i); + if (char === 10) { + if (lineStart !== i || !previousCharWasCR) + ++lineNum; + lineStart = i + 1; + previousCharWasCR = false; + } else if (char === 13) { + ++lineNum; + lineStart = i + 1; + previousCharWasCR = true; + } + } + report = lineNum > 1 ? report + (" (at line " + lineNum + ", character " + (offset - lineStart + 1) + ")\n") : report + (" (at character " + (offset + 1) + ")\n"); + for (i = offset; i < lineEnd; ++i) { + if (!(i >= 0)) + return A.ioore(source, i); + char = source.charCodeAt(i); + if (char === 10 || char === 13) { + lineEnd = i; + break; + } + } + if (lineEnd - lineStart > 78) + if (offset - lineStart < 75) { + end = lineStart + 75; + start = lineStart; + prefix = ""; + postfix = "..."; + } else { + if (lineEnd - offset < 75) { + start = lineEnd - 75; + end = lineEnd; + postfix = ""; + } else { + start = offset - 36; + end = offset + 36; + postfix = "..."; + } + prefix = "..."; + } + else { + end = lineEnd; + start = lineStart; + prefix = ""; + postfix = ""; + } + return report + prefix + B.JSString_methods.substring$2(source, start, end) + postfix + "\n" + B.JSString_methods.$mul(" ", offset - start + prefix.length) + "^\n"; + } else + return offset != null ? report + (" (at offset " + A.S(offset) + ")") : report; + } + }; + A.Iterable.prototype = { + get$length(_) { + var count, + it = this.get$iterator(this); + for (count = 0; it.moveNext$0();) + ++count; + return count; + }, + elementAt$1(_, index) { + var iterator, skipCount; + A.RangeError_checkNotNegative(index, "index"); + iterator = this.get$iterator(this); + for (skipCount = index; iterator.moveNext$0();) { + if (skipCount === 0) + return iterator.get$current(); + --skipCount; + } + throw A.wrapException(A.IndexError$withLength(index, index - skipCount, this, null, "index")); + }, + toString$0(_) { + return A.Iterable_iterableToShortString(this, "(", ")"); + } + }; + A.Null.prototype = { + get$hashCode(_) { + return A.Object.prototype.get$hashCode.call(this, 0); + }, + toString$0(_) { + return "null"; + } + }; + A.Object.prototype = {$isObject: 1, + $eq(_, other) { + return this === other; + }, + get$hashCode(_) { + return A.Primitives_objectHashCode(this); + }, + toString$0(_) { + return "Instance of '" + A.Primitives_objectTypeName(this) + "'"; + }, + noSuchMethod$1(_, invocation) { + throw A.wrapException(A.NoSuchMethodError_NoSuchMethodError$withInvocation(this, type$.Invocation._as(invocation))); + }, + get$runtimeType(_) { + return A.getRuntimeTypeOfDartObject(this); + }, + toString() { + return this.toString$0(this); + } + }; + A._StringStackTrace.prototype = { + toString$0(_) { + return this._stackTrace; + }, + $isStackTrace: 1 + }; + A.StringBuffer.prototype = { + get$length(_) { + return this._contents.length; + }, + toString$0(_) { + var t1 = this._contents; + return t1.charCodeAt(0) == 0 ? t1 : t1; + }, + $isStringSink: 1 + }; + A.promiseToFuture_closure.prototype = { + call$1(r) { + return this.completer.complete$1(this.T._eval$1("0/?")._as(r)); + }, + $signature: 3 + }; + A.promiseToFuture_closure0.prototype = { + call$1(e) { + if (e == null) + return this.completer.completeError$1(new A.NullRejectionException(e === undefined)); + return this.completer.completeError$1(e); + }, + $signature: 3 + }; + A.dartify_convert.prototype = { + call$1(o) { + var t1, millisSinceEpoch, proto, t2, dartObject, originalKeys, dartKeys, i, jsKey, dartKey, l, $length; + if (A._noDartifyRequired(o)) + return o; + t1 = this._convertedObjects; + o.toString; + if (t1.containsKey$1(o)) + return t1.$index(0, o); + if (o instanceof Date) { + millisSinceEpoch = o.getTime(); + if (Math.abs(millisSinceEpoch) > 864e13) + A.throwExpression(A.ArgumentError$("DateTime is outside valid range: " + millisSinceEpoch, null)); + A.checkNotNullable(true, "isUtc", type$.bool); + return new A.DateTime(millisSinceEpoch, true); + } + if (o instanceof RegExp) + throw A.wrapException(A.ArgumentError$("structured clone of RegExp", null)); + if (typeof Promise != "undefined" && o instanceof Promise) + return A.promiseToFuture(o, type$.nullable_Object); + proto = Object.getPrototypeOf(o); + if (proto === Object.prototype || proto === null) { + t2 = type$.nullable_Object; + dartObject = A.LinkedHashMap_LinkedHashMap$_empty(t2, t2); + t1.$indexSet(0, o, dartObject); + originalKeys = Object.keys(o); + dartKeys = []; + for (t1 = J.getInterceptor$ax(originalKeys), t2 = t1.get$iterator(originalKeys); t2.moveNext$0();) + dartKeys.push(A.dartify(t2.get$current())); + for (i = 0; i < t1.get$length(originalKeys); ++i) { + jsKey = t1.$index(originalKeys, i); + if (!(i < dartKeys.length)) + return A.ioore(dartKeys, i); + dartKey = dartKeys[i]; + if (jsKey != null) + dartObject.$indexSet(0, dartKey, this.call$1(o[jsKey])); + } + return dartObject; + } + if (o instanceof Array) { + l = o; + dartObject = []; + t1.$indexSet(0, o, dartObject); + $length = A._asInt(o.length); + for (t1 = J.getInterceptor$asx(l), i = 0; i < $length; ++i) + dartObject.push(this.call$1(t1.$index(l, i))); + return dartObject; + } + return o; + }, + $signature: 20 + }; + A.NullRejectionException.prototype = { + toString$0(_) { + return "Promise was rejected with a value of `" + (this.isUndefined ? "undefined" : "null") + "`."; + } + }; + A._JSRandom.prototype = { + nextInt$1(max) { + if (max <= 0 || max > 4294967296) + throw A.wrapException(A.RangeError$("max must be in range 0 < max \u2264 2^32, was " + max)); + return Math.random() * max >>> 0; + } + }; + A.AsyncMemoizer.prototype = {}; + A.Level.prototype = { + $eq(_, other) { + if (other == null) + return false; + return other instanceof A.Level && this.value === other.value; + }, + get$hashCode(_) { + return this.value; + }, + toString$0(_) { + return this.name; + } + }; + A.LogRecord.prototype = { + toString$0(_) { + return "[" + this.level.name + "] " + this.loggerName + ": " + this.message; + } + }; + A.Logger.prototype = { + get$fullName() { + var t1 = this.parent, + t2 = t1 == null ? null : t1.name.length !== 0, + t3 = this.name; + return t2 === true ? t1.get$fullName() + "." + t3 : t3; + }, + get$level() { + var t1, effectiveLevel; + if (this.parent == null) { + t1 = this._level; + t1.toString; + effectiveLevel = t1; + } else { + t1 = $.$get$Logger_root()._level; + t1.toString; + effectiveLevel = t1; + } + return effectiveLevel; + }, + log$4(logLevel, message, error, stackTrace) { + var record, _this = this, + t1 = logLevel.value; + if (t1 >= _this.get$level().value) { + if (t1 >= 2000) { + A.StackTrace_current(); + logLevel.toString$0(0); + } + t1 = _this.get$fullName(); + Date.now(); + $.LogRecord__nextNumber = $.LogRecord__nextNumber + 1; + record = new A.LogRecord(logLevel, message, t1); + if (_this.parent == null) + _this._publish$1(record); + else + $.$get$Logger_root()._publish$1(record); + } + }, + _publish$1(record) { + return null; + } + }; + A.Logger_Logger_closure.prototype = { + call$0() { + var dot, $parent, t1, + thisName = this.name; + if (B.JSString_methods.startsWith$1(thisName, ".")) + A.throwExpression(A.ArgumentError$("name shouldn't start with a '.'", null)); + if (B.JSString_methods.endsWith$1(thisName, ".")) + A.throwExpression(A.ArgumentError$("name shouldn't end with a '.'", null)); + dot = B.JSString_methods.lastIndexOf$1(thisName, "."); + if (dot === -1) + $parent = thisName !== "" ? A.Logger_Logger("") : null; + else { + $parent = A.Logger_Logger(B.JSString_methods.substring$2(thisName, 0, dot)); + thisName = B.JSString_methods.substring$1(thisName, dot + 1); + } + t1 = new A.Logger(thisName, $parent, A.LinkedHashMap_LinkedHashMap$_empty(type$.String, type$.Logger)); + if ($parent == null) + t1._level = B.Level_INFO_800; + else + $parent._children.$indexSet(0, thisName, t1); + return t1; + }, + $signature: 21 + }; + A.Pool.prototype = { + request$0() { + var t1, t2, _this = this; + if ((_this._closeMemo._completer.future._state & 30) !== 0) + throw A.wrapException(A.StateError$("request() may not be called on a closed Pool.")); + t1 = _this._allocatedResources; + if (t1 < _this._maxAllocatedResources) { + _this._allocatedResources = t1 + 1; + return A.Future_Future$value(new A.PoolResource(_this), type$.PoolResource); + } else { + t1 = _this._onReleaseCallbacks; + if (!t1.get$isEmpty(0)) + return _this._runOnRelease$1(t1.removeFirst$0()); + else { + t1 = new A._Future($.Zone__current, type$._Future_PoolResource); + t2 = _this._requestedResources; + t2._add$1(t2.$ti._precomputed1._as(new A._AsyncCompleter(t1, type$._AsyncCompleter_PoolResource))); + _this._resetTimer$0(); + return t1; + } + } + }, + withResource$1$1(callback, $T) { + return this.withResource$body$Pool($T._eval$1("0/()")._as(callback), $T, $T); + }, + withResource$body$Pool(callback, $T, $async$type) { + var $async$goto = 0, + $async$completer = A._makeAsyncAwaitCompleter($async$type), + $async$returnValue, $async$handler = 2, $async$currentError, $async$next = [], $async$self = this, resource, t1, t2; + var $async$withResource$1$1 = A._wrapJsFunctionForAsync(function($async$errorCode, $async$result) { + if ($async$errorCode === 1) { + $async$currentError = $async$result; + $async$goto = $async$handler; + } + while (true) + switch ($async$goto) { + case 0: + // Function start + if (($async$self._closeMemo._completer.future._state & 30) !== 0) + throw A.wrapException(A.StateError$("withResource() may not be called on a closed Pool.")); + $async$goto = 3; + return A._asyncAwait($async$self.request$0(), $async$withResource$1$1); + case 3: + // returning from await. + resource = $async$result; + $async$handler = 4; + t1 = callback.call$0(); + $async$goto = 7; + return A._asyncAwait($T._eval$1("Future<0>")._is(t1) ? t1 : A._Future$value($T._as(t1), $T), $async$withResource$1$1); + case 7: + // returning from await. + t1 = $async$result; + $async$returnValue = t1; + $async$next = [1]; + // goto finally + $async$goto = 5; + break; + $async$next.push(6); + // goto finally + $async$goto = 5; + break; + case 4: + // uncaught + $async$next = [2]; + case 5: + // finally + $async$handler = 2; + t1 = resource; + if (t1._released) + A.throwExpression(A.StateError$("A PoolResource may only be released once.")); + t1._released = true; + t1 = t1._pool; + t1._resetTimer$0(); + t2 = t1._requestedResources; + if (!t2.get$isEmpty(0)) + t2.removeFirst$0().complete$1(new A.PoolResource(t1)); + else { + t2 = --t1._allocatedResources; + if ((t1._closeMemo._completer.future._state & 30) !== 0 && t2 === 0) + null.close$0(); + } + // goto the next finally handler + $async$goto = $async$next.pop(); + break; + case 6: + // after finally + case 1: + // return + return A._asyncReturn($async$returnValue, $async$completer); + case 2: + // rethrow + return A._asyncRethrow($async$currentError, $async$completer); + } + }); + return A._asyncStartSync($async$withResource$1$1, $async$completer); + }, + _runOnRelease$1(onRelease) { + var t1 = A.Future_Future$sync(type$.dynamic_Function._as(onRelease), type$.dynamic).then$1$1(new A.Pool__runOnRelease_closure(this), type$.Null), + onError = new A.Pool__runOnRelease_closure0(this), + t2 = t1.$ti, + t3 = $.Zone__current; + if (t3 !== B.C__RootZone) + onError = A._registerErrorHandler(onError, t3); + t1._addListener$1(new A._FutureListener(new A._Future(t3, t2), 2, null, onError, t2._eval$1("@<1>")._bind$1(t2._precomputed1)._eval$1("_FutureListener<1,2>"))); + t1 = new A._Future($.Zone__current, type$._Future_PoolResource); + t2 = this._onReleaseCompleters; + t2._add$1(t2.$ti._precomputed1._as(new A._SyncCompleter(t1, type$._SyncCompleter_PoolResource))); + return t1; + }, + _resetTimer$0() { + var t2, + t1 = this._timer; + if (t1 == null) + return; + t2 = this._requestedResources; + if (t2._head === t2._tail) + t1._restartable_timer$_timer.cancel$0(); + else { + t1._restartable_timer$_timer.cancel$0(); + t1._restartable_timer$_timer = A.Timer_Timer(t1._restartable_timer$_duration, t1._callback); + } + } + }; + A.Pool__runOnRelease_closure.prototype = { + call$1(value) { + var t1 = this.$this; + t1._onReleaseCompleters.removeFirst$0().complete$1(new A.PoolResource(t1)); + }, + $signature: 4 + }; + A.Pool__runOnRelease_closure0.prototype = { + call$2(error, stackTrace) { + type$.Object._as(error); + type$.StackTrace._as(stackTrace); + this.$this._onReleaseCompleters.removeFirst$0().completeError$2(error, stackTrace); + }, + $signature: 5 + }; + A.PoolResource.prototype = {}; + A.SseClient.prototype = { + SseClient$2$debugKey(serverUrl, debugKey) { + var t2, t3, _this = this, + t1 = serverUrl + "?sseClientId=" + _this._clientId; + _this.__SseClient__serverUrl_A = t1; + t2 = type$.JSObject; + t1 = t2._as(new self.EventSource(t1, {withCredentials: true})); + _this.__SseClient__eventSource_A = t1; + new A._EventStream(t1, "open", false, type$._EventStream_JSObject).get$first(0).whenComplete$1(new A.SseClient_closure(_this)); + t1 = type$.Function; + t3 = type$.JavaScriptFunction; + _this.__SseClient__eventSource_A.addEventListener("message", t3._as(A.allowInterop(_this.get$_onIncomingMessage(), t1))); + _this.__SseClient__eventSource_A.addEventListener("control", t3._as(A.allowInterop(_this.get$_onIncomingControlMessage(), t1))); + t1 = type$.nullable_void_Function_JSObject; + A._EventStreamSubscription$(_this.__SseClient__eventSource_A, "open", t1._as(new A.SseClient_closure0(_this)), false, t2); + A._EventStreamSubscription$(_this.__SseClient__eventSource_A, "error", t1._as(new A.SseClient_closure1(_this)), false, t2); + }, + close$0() { + var _this = this, + t1 = _this.__SseClient__eventSource_A; + t1 === $ && A.throwLateFieldNI("_eventSource"); + t1.close(); + if ((_this._onConnected.future._state & 30) === 0) { + t1 = _this._outgoingController; + new A._ControllerStream(t1, A._instanceType(t1)._eval$1("_ControllerStream<1>")).listen$2$cancelOnError(null, true).asFuture$1$1(null, type$.void); + } + _this._incomingController.close$0(); + _this._outgoingController.close$0(); + }, + _closeWithError$1(error) { + var stackTrace, t2, + t1 = this._incomingController; + A.checkNotNullable(error, "error", type$.Object); + if (t1._state >= 4) + A.throwExpression(t1._badEventState$0()); + stackTrace = A.AsyncError_defaultStackTrace(error); + t2 = t1._state; + if ((t2 & 1) !== 0) + t1._sendError$2(error, stackTrace); + else if ((t2 & 3) === 0) + t1._ensurePendingEvents$0().add$1(0, new A._DelayedError(error, stackTrace)); + this.close$0(); + t1 = this._onConnected; + if ((t1.future._state & 30) === 0) + t1.completeError$1(error); + }, + _onIncomingControlMessage$1(message) { + var data = type$.JSObject._as(message).data; + if (J.$eq$(A.dartify(data), "close")) + this.close$0(); + else + throw A.wrapException(A.UnsupportedError$("[" + this._clientId + '] Illegal Control Message "' + A.S(data) + '"')); + }, + _onIncomingMessage$1(message) { + this._incomingController.add$1(0, A._asString(B.C_JsonCodec.decode$2$reviver(A._asString(type$.JSObject._as(message).data), null))); + }, + _onOutgoingDone$0() { + this.close$0(); + }, + _onOutgoingMessage$1(message) { + return this._onOutgoingMessage$body$SseClient(A._asStringQ(message)); + }, + _onOutgoingMessage$body$SseClient(message) { + var $async$goto = 0, + $async$completer = A._makeAsyncAwaitCompleter(type$.void), + $async$self = this, t1; + var $async$_onOutgoingMessage$1 = A._wrapJsFunctionForAsync(function($async$errorCode, $async$result) { + if ($async$errorCode === 1) + return A._asyncRethrow($async$result, $async$completer); + while (true) + switch ($async$goto) { + case 0: + // Function start + t1 = {}; + t1.encodedMessage = null; + $async$goto = 2; + return A._asyncAwait($.$get$_requestPool().withResource$1$1(new A.SseClient__onOutgoingMessage_closure(t1, $async$self, message), type$.Null), $async$_onOutgoingMessage$1); + case 2: + // returning from await. + // implicit return + return A._asyncReturn(null, $async$completer); + } + }); + return A._asyncStartSync($async$_onOutgoingMessage$1, $async$completer); + } + }; + A.SseClient_closure.prototype = { + call$0() { + var t2, + t1 = this.$this; + t1._onConnected.complete$0(); + t2 = t1._outgoingController; + new A._ControllerStream(t2, A._instanceType(t2)._eval$1("_ControllerStream<1>")).listen$2$onDone(t1.get$_onOutgoingMessage(), t1.get$_onOutgoingDone()); + }, + $signature: 2 + }; + A.SseClient_closure0.prototype = { + call$1(_) { + var t1 = this.$this._errorTimer; + if (t1 != null) + t1.cancel$0(); + }, + $signature: 1 + }; + A.SseClient_closure1.prototype = { + call$1(error) { + var t1 = this.$this, + t2 = t1._errorTimer; + t2 = t2 == null ? null : t2._handle != null; + if (t2 !== true) + t1._errorTimer = A.Timer_Timer(B.Duration_5000000, new A.SseClient__closure(t1, error)); + }, + $signature: 1 + }; + A.SseClient__closure.prototype = { + call$0() { + this.$this._closeWithError$1(this.error); + }, + $signature: 0 + }; + A.SseClient__onOutgoingMessage_closure.prototype = { + call$0() { + var $async$goto = 0, + $async$completer = A._makeAsyncAwaitCompleter(type$.Null), + $async$handler = 1, $async$currentError, $async$self = this, e, e0, url, error, augmentedError, exception, t1, t2, $async$exception; + var $async$call$0 = A._wrapJsFunctionForAsync(function($async$errorCode, $async$result) { + if ($async$errorCode === 1) { + $async$currentError = $async$result; + $async$goto = $async$handler; + } + while (true) + switch ($async$goto) { + case 0: + // Function start + try { + $async$self._box_0.encodedMessage = B.C_JsonCodec.encode$2$toEncodable($async$self.message, null); + } catch (exception) { + t1 = A.unwrapException(exception); + if (t1 instanceof A.JsonUnsupportedObjectError) { + e = t1; + t1 = $async$self.$this; + t1._logger.log$4(B.Level_WARNING_900, "[" + t1._clientId + "] Unable to encode outgoing message: " + A.S(e), null, null); + } else if (t1 instanceof A.ArgumentError) { + e0 = t1; + t1 = $async$self.$this; + t1._logger.log$4(B.Level_WARNING_900, "[" + t1._clientId + "] Invalid argument: " + A.S(e0), null, null); + } else + throw exception; + } + $async$handler = 3; + t1 = $async$self.$this; + t2 = t1.__SseClient__serverUrl_A; + t2 === $ && A.throwLateFieldNI("_serverUrl"); + url = t2 + "&messageId=" + ++t1._lastMessageId; + t1 = $async$self._box_0.encodedMessage; + if (t1 == null) + t1 = null; + t1 = {method: "POST", body: t1, credentials: "include"}; + t2 = type$.JSObject; + $async$goto = 6; + return A._asyncAwait(A.promiseToFuture(t2._as(t2._as(self.window).fetch(url, t1)), t2), $async$call$0); + case 6: + // returning from await. + $async$handler = 1; + // goto after finally + $async$goto = 5; + break; + case 3: + // catch + $async$handler = 2; + $async$exception = $async$currentError; + error = A.unwrapException($async$exception); + t1 = $async$self.$this; + augmentedError = "[" + t1._clientId + "] SSE client failed to send " + A.S($async$self.message) + ":\n " + A.S(error); + t1._logger.log$4(B.Level_SEVERE_1000, augmentedError, null, null); + t1._closeWithError$1(augmentedError); + // goto after finally + $async$goto = 5; + break; + case 2: + // uncaught + // goto rethrow + $async$goto = 1; + break; + case 5: + // after finally + // implicit return + return A._asyncReturn(null, $async$completer); + case 1: + // rethrow + return A._asyncRethrow($async$currentError, $async$completer); + } + }); + return A._asyncStartSync($async$call$0, $async$completer); + }, + $signature: 7 + }; + A.generateUuidV4_generateBits.prototype = { + call$1(bitCount) { + return this.random.nextInt$1(B.JSInt_methods._shlPositive$1(1, bitCount)); + }, + $signature: 23 + }; + A.generateUuidV4_printDigits.prototype = { + call$2(value, count) { + return B.JSString_methods.padLeft$2(B.JSInt_methods.toRadixString$1(value, 16), count, "0"); + }, + $signature: 11 + }; + A.generateUuidV4_bitsDigits.prototype = { + call$2(bitCount, digitCount) { + return this.printDigits.call$2(this.generateBits.call$1(bitCount), digitCount); + }, + $signature: 11 + }; + A.StreamChannelMixin.prototype = {}; + A.EventStreamProvider.prototype = {}; + A._EventStream.prototype = { + listen$4$cancelOnError$onDone$onError(onData, cancelOnError, onDone, onError) { + var t1 = A._instanceType(this); + t1._eval$1("~(1)?")._as(onData); + type$.nullable_void_Function._as(onDone); + return A._EventStreamSubscription$(this._target, this._eventType, onData, false, t1._precomputed1); + } + }; + A._ElementEventStreamImpl.prototype = {}; + A._EventStreamSubscription.prototype = { + cancel$0() { + var _this = this, + emptyFuture = A.Future_Future$value(null, type$.void); + if (_this._target == null) + return emptyFuture; + _this._unlisten$0(); + _this._streams$_onData = _this._target = null; + return emptyFuture; + }, + onData$1(handleData) { + var t1, _this = this; + _this.$ti._eval$1("~(1)?")._as(handleData); + if (_this._target == null) + throw A.wrapException(A.StateError$("Subscription has been canceled.")); + _this._unlisten$0(); + t1 = A._wrapZone(new A._EventStreamSubscription_onData_closure(handleData), type$.JSObject); + t1 = t1 == null ? null : type$.JavaScriptFunction._as(A.allowInterop(t1, type$.Function)); + _this._streams$_onData = t1; + _this._tryResume$0(); + }, + _tryResume$0() { + var t1 = this._streams$_onData; + if (t1 != null) + this._target.addEventListener(this._eventType, t1, false); + }, + _unlisten$0() { + var t1 = this._streams$_onData; + if (t1 != null) + this._target.removeEventListener(this._eventType, t1, false); + }, + $isStreamSubscription: 1 + }; + A._EventStreamSubscription_closure.prototype = { + call$1(e) { + return this.onData.call$1(type$.JSObject._as(e)); + }, + $signature: 1 + }; + A._EventStreamSubscription_onData_closure.prototype = { + call$1(e) { + return this.handleData.call$1(type$.JSObject._as(e)); + }, + $signature: 1 + }; + A.main_closure.prototype = { + call$1(_) { + this.channel._outgoingController.close$0(); + }, + $signature: 1 + }; + A.main_closure0.prototype = { + call$1(s) { + var count, t1, t2, t3, i, t4, t5, lastEvent; + A._asString(s); + if (B.JSString_methods.startsWith$1(s, "send ")) { + count = A.int_parse(B.JSArray_methods.get$last(s.split(" ")), null); + for (t1 = this.channel._outgoingController, t2 = A._instanceType(t1), t3 = t2._precomputed1, t2 = t2._eval$1("_DelayedData<1>"), i = 0; i < count; ++i) { + t4 = t3._as("" + i); + t5 = t1._state; + if (t5 >= 4) + A.throwExpression(t1._badEventState$0()); + if ((t5 & 1) !== 0) + t1._sendData$1(t4); + else if ((t5 & 3) === 0) { + t5 = t1._ensurePendingEvents$0(); + t4 = new A._DelayedData(t4, t2); + lastEvent = t5.lastPendingEvent; + if (lastEvent == null) + t5.firstPendingEvent = t5.lastPendingEvent = t4; + else { + lastEvent.set$next(t4); + t5.lastPendingEvent = t4; + } + } + } + } else { + t1 = this.channel._outgoingController; + t1.add$1(0, A._instanceType(t1)._precomputed1._as(s)); + } + }, + $signature: 24 + }; + (function aliases() { + var _ = J.LegacyJavaScriptObject.prototype; + _.super$LegacyJavaScriptObject$toString = _.toString$0; + })(); + (function installTearOffs() { + var _static_1 = hunkHelpers._static_1, + _static_0 = hunkHelpers._static_0, + _static_2 = hunkHelpers._static_2, + _instance_2_u = hunkHelpers._instance_2u, + _instance_1_u = hunkHelpers._instance_1u, + _instance_0_u = hunkHelpers._instance_0u; + _static_1(A, "async__AsyncRun__scheduleImmediateJsOverride$closure", "_AsyncRun__scheduleImmediateJsOverride", 6); + _static_1(A, "async__AsyncRun__scheduleImmediateWithSetImmediate$closure", "_AsyncRun__scheduleImmediateWithSetImmediate", 6); + _static_1(A, "async__AsyncRun__scheduleImmediateWithTimer$closure", "_AsyncRun__scheduleImmediateWithTimer", 6); + _static_0(A, "async___startMicrotaskLoop$closure", "_startMicrotaskLoop", 0); + _static_1(A, "async___nullDataHandler$closure", "_nullDataHandler", 3); + _static_2(A, "async___nullErrorHandler$closure", "_nullErrorHandler", 9); + _static_0(A, "async___nullDoneHandler$closure", "_nullDoneHandler", 0); + _instance_2_u(A._Future.prototype, "get$_completeError", "_completeError$2", 9); + _static_1(A, "convert___defaultToEncodable$closure", "_defaultToEncodable", 8); + var _; + _instance_1_u(_ = A.SseClient.prototype, "get$_onIncomingControlMessage", "_onIncomingControlMessage$1", 1); + _instance_1_u(_, "get$_onIncomingMessage", "_onIncomingMessage$1", 1); + _instance_0_u(_, "get$_onOutgoingDone", "_onOutgoingDone$0", 0); + _instance_1_u(_, "get$_onOutgoingMessage", "_onOutgoingMessage$1", 22); + })(); + (function inheritance() { + var _mixin = hunkHelpers.mixin, + _inherit = hunkHelpers.inherit, + _inheritMany = hunkHelpers.inheritMany; + _inherit(A.Object, null); + _inheritMany(A.Object, [A.JS_CONST, J.Interceptor, J.ArrayIterator, A.Error, A.Closure, A.Iterable, A.ListIterator, A.FixedLengthListMixin, A.Symbol, A.MapView, A.ConstantMap, A.JSInvocationMirror, A.TypeErrorDecoder, A.NullThrownFromJavaScriptException, A.ExceptionAndStackTrace, A._StackTrace, A._Required, A.MapBase, A.LinkedHashMapCell, A.LinkedHashMapKeyIterator, A.StringMatch, A.Rti, A._FunctionParameters, A._Type, A._TimerImpl, A._AsyncAwaitCompleter, A.AsyncError, A._Completer, A._FutureListener, A._Future, A._AsyncCallbackEntry, A.Stream, A._StreamController, A._AsyncStreamControllerDispatch, A._BufferingStreamSubscription, A._StreamSinkWrapper, A._DelayedEvent, A._DelayedDone, A._PendingEvents, A._StreamIterator, A._Zone, A._HashMapKeyIterator, A.ListBase, A._UnmodifiableMapMixin, A._ListQueueIterator, A.Codec, A.Converter, A._JsonStringifier, A.DateTime, A.Duration, A.OutOfMemoryError, A.StackOverflowError, A._Exception, A.FormatException, A.Null, A._StringStackTrace, A.StringBuffer, A.NullRejectionException, A._JSRandom, A.AsyncMemoizer, A.Level, A.LogRecord, A.Logger, A.Pool, A.PoolResource, A.StreamChannelMixin, A.EventStreamProvider, A._EventStreamSubscription]); + _inheritMany(J.Interceptor, [J.JSBool, J.JSNull, J.JavaScriptObject, J.JavaScriptBigInt, J.JavaScriptSymbol, J.JSNumber, J.JSString]); + _inheritMany(J.JavaScriptObject, [J.LegacyJavaScriptObject, J.JSArray, A.NativeByteBuffer, A.NativeTypedData]); + _inheritMany(J.LegacyJavaScriptObject, [J.PlainJavaScriptObject, J.UnknownJavaScriptObject, J.JavaScriptFunction]); + _inherit(J.JSUnmodifiableArray, J.JSArray); + _inheritMany(J.JSNumber, [J.JSInt, J.JSNumNotInt]); + _inheritMany(A.Error, [A.LateError, A.TypeError, A.JsNoSuchMethodError, A.UnknownJsTypeError, A._CyclicInitializationError, A.RuntimeError, A._Error, A.JsonUnsupportedObjectError, A.AssertionError, A.ArgumentError, A.NoSuchMethodError, A.UnsupportedError, A.UnimplementedError, A.StateError, A.ConcurrentModificationError]); + _inheritMany(A.Closure, [A.Closure0Args, A.Closure2Args, A.TearOffClosure, A.initHooks_closure, A.initHooks_closure1, A._AsyncRun__initializeScheduleImmediate_internalCallback, A._AsyncRun__initializeScheduleImmediate_closure, A._awaitOnObject_closure, A._Future__chainForeignFuture_closure, A._Future__propagateToListeners_handleWhenCompleteCallback_closure, A.Stream_length_closure, A.Stream_first_closure0, A._RootZone_bindUnaryCallbackGuarded_closure, A.promiseToFuture_closure, A.promiseToFuture_closure0, A.dartify_convert, A.Pool__runOnRelease_closure, A.SseClient_closure0, A.SseClient_closure1, A.generateUuidV4_generateBits, A._EventStreamSubscription_closure, A._EventStreamSubscription_onData_closure, A.main_closure, A.main_closure0]); + _inheritMany(A.Closure0Args, [A.nullFuture_closure, A._AsyncRun__scheduleImmediateJsOverride_internalCallback, A._AsyncRun__scheduleImmediateWithSetImmediate_internalCallback, A._TimerImpl_internalCallback, A._Future__addListener_closure, A._Future__prependListeners_closure, A._Future__chainForeignFuture_closure1, A._Future__chainCoreFutureAsync_closure, A._Future__asyncCompleteWithValue_closure, A._Future__asyncCompleteError_closure, A._Future__propagateToListeners_handleWhenCompleteCallback, A._Future__propagateToListeners_handleValueCallback, A._Future__propagateToListeners_handleError, A.Stream_length_closure0, A.Stream_first_closure, A._StreamController__subscribe_closure, A._StreamController__recordCancel_complete, A._BufferingStreamSubscription_asFuture_closure, A._BufferingStreamSubscription_asFuture__closure, A._BufferingStreamSubscription__sendError_sendError, A._BufferingStreamSubscription__sendDone_sendDone, A._PendingEvents_schedule_closure, A._cancelAndValue_closure, A._rootHandleError_closure, A._RootZone_bindCallbackGuarded_closure, A.Logger_Logger_closure, A.SseClient_closure, A.SseClient__closure, A.SseClient__onOutgoingMessage_closure]); + _inherit(A.EfficientLengthIterable, A.Iterable); + _inheritMany(A.EfficientLengthIterable, [A.ListIterable, A.LinkedHashMapKeyIterable, A._HashMapKeyIterable]); + _inherit(A._UnmodifiableMapView_MapView__UnmodifiableMapMixin, A.MapView); + _inherit(A.UnmodifiableMapView, A._UnmodifiableMapView_MapView__UnmodifiableMapMixin); + _inherit(A.ConstantMapView, A.UnmodifiableMapView); + _inherit(A.ConstantStringMap, A.ConstantMap); + _inheritMany(A.Closure2Args, [A.Primitives_functionNoSuchMethod_closure, A.initHooks_closure0, A._awaitOnObject_closure0, A._wrapJsFunctionForAsync_closure, A._Future__chainForeignFuture_closure0, A._BufferingStreamSubscription_asFuture_closure0, A.MapBase_mapToString_closure, A._JsonStringifier_writeMap_closure, A.NoSuchMethodError_toString_closure, A.Pool__runOnRelease_closure0, A.generateUuidV4_printDigits, A.generateUuidV4_bitsDigits]); + _inherit(A.NullError, A.TypeError); + _inheritMany(A.TearOffClosure, [A.StaticClosure, A.BoundClosure]); + _inheritMany(A.MapBase, [A.JsLinkedHashMap, A._HashMap, A._JsonMap]); + _inheritMany(A.NativeTypedData, [A.NativeByteData, A.NativeTypedArray]); + _inheritMany(A.NativeTypedArray, [A._NativeTypedArrayOfDouble_NativeTypedArray_ListMixin, A._NativeTypedArrayOfInt_NativeTypedArray_ListMixin]); + _inherit(A._NativeTypedArrayOfDouble_NativeTypedArray_ListMixin_FixedLengthListMixin, A._NativeTypedArrayOfDouble_NativeTypedArray_ListMixin); + _inherit(A.NativeTypedArrayOfDouble, A._NativeTypedArrayOfDouble_NativeTypedArray_ListMixin_FixedLengthListMixin); + _inherit(A._NativeTypedArrayOfInt_NativeTypedArray_ListMixin_FixedLengthListMixin, A._NativeTypedArrayOfInt_NativeTypedArray_ListMixin); + _inherit(A.NativeTypedArrayOfInt, A._NativeTypedArrayOfInt_NativeTypedArray_ListMixin_FixedLengthListMixin); + _inheritMany(A.NativeTypedArrayOfDouble, [A.NativeFloat32List, A.NativeFloat64List]); + _inheritMany(A.NativeTypedArrayOfInt, [A.NativeInt16List, A.NativeInt32List, A.NativeInt8List, A.NativeUint16List, A.NativeUint32List, A.NativeUint8ClampedList, A.NativeUint8List]); + _inherit(A._TypeError, A._Error); + _inheritMany(A._Completer, [A._AsyncCompleter, A._SyncCompleter]); + _inherit(A._AsyncStreamController, A._StreamController); + _inheritMany(A.Stream, [A._StreamImpl, A._EventStream]); + _inherit(A._ControllerStream, A._StreamImpl); + _inherit(A._ControllerSubscription, A._BufferingStreamSubscription); + _inheritMany(A._DelayedEvent, [A._DelayedData, A._DelayedError]); + _inherit(A._RootZone, A._Zone); + _inherit(A._IdentityHashMap, A._HashMap); + _inheritMany(A.ListIterable, [A.ListQueue, A._JsonMapKeyIterable]); + _inherit(A.JsonCyclicError, A.JsonUnsupportedObjectError); + _inherit(A.JsonCodec, A.Codec); + _inheritMany(A.Converter, [A.JsonEncoder, A.JsonDecoder]); + _inherit(A._JsonStringStringifier, A._JsonStringifier); + _inheritMany(A.ArgumentError, [A.RangeError, A.IndexError]); + _inherit(A.SseClient, A.StreamChannelMixin); + _inherit(A._ElementEventStreamImpl, A._EventStream); + _mixin(A._NativeTypedArrayOfDouble_NativeTypedArray_ListMixin, A.ListBase); + _mixin(A._NativeTypedArrayOfDouble_NativeTypedArray_ListMixin_FixedLengthListMixin, A.FixedLengthListMixin); + _mixin(A._NativeTypedArrayOfInt_NativeTypedArray_ListMixin, A.ListBase); + _mixin(A._NativeTypedArrayOfInt_NativeTypedArray_ListMixin_FixedLengthListMixin, A.FixedLengthListMixin); + _mixin(A._AsyncStreamController, A._AsyncStreamControllerDispatch); + _mixin(A._UnmodifiableMapView_MapView__UnmodifiableMapMixin, A._UnmodifiableMapMixin); + })(); + var init = { + typeUniverse: {eC: new Map(), tR: {}, eT: {}, tPV: {}, sEA: []}, + mangledGlobalNames: {int: "int", double: "double", num: "num", String: "String", bool: "bool", Null: "Null", List: "List", Object: "Object", Map: "Map"}, + mangledNames: {}, + types: ["~()", "~(JSObject)", "Null()", "~(@)", "Null(@)", "Null(Object,StackTrace)", "~(~())", "Future()", "@(@)", "~(Object,StackTrace)", "~(Object?,Object?)", "String(int,int)", "~(String,@)", "@(@,String)", "@(String)", "Null(~())", "Null(@,StackTrace)", "~(int,@)", "_Future<@>(@)", "~(Symbol0,@)", "Object?(Object?)", "Logger()", "~(String?)", "int(int)", "~(String)"], + interceptorsByTag: null, + leafTags: null, + arrayRti: Symbol("$ti") + }; + A._Universe_addRules(init.typeUniverse, JSON.parse('{"PlainJavaScriptObject":"LegacyJavaScriptObject","UnknownJavaScriptObject":"LegacyJavaScriptObject","JavaScriptFunction":"LegacyJavaScriptObject","JSBool":{"bool":[],"TrustedGetRuntimeType":[]},"JSNull":{"Null":[],"TrustedGetRuntimeType":[]},"JavaScriptObject":{"JSObject":[]},"LegacyJavaScriptObject":{"JSObject":[]},"JSArray":{"List":["1"],"JSObject":[],"Iterable":["1"]},"JSUnmodifiableArray":{"JSArray":["1"],"List":["1"],"JSObject":[],"Iterable":["1"]},"JSNumber":{"double":[],"num":[]},"JSInt":{"double":[],"int":[],"num":[],"TrustedGetRuntimeType":[]},"JSNumNotInt":{"double":[],"num":[],"TrustedGetRuntimeType":[]},"JSString":{"String":[],"Pattern":[],"TrustedGetRuntimeType":[]},"LateError":{"Error":[]},"EfficientLengthIterable":{"Iterable":["1"]},"ListIterable":{"Iterable":["1"]},"Symbol":{"Symbol0":[]},"ConstantMapView":{"UnmodifiableMapView":["1","2"],"_UnmodifiableMapView_MapView__UnmodifiableMapMixin":["1","2"],"MapView":["1","2"],"_UnmodifiableMapMixin":["1","2"],"Map":["1","2"]},"ConstantMap":{"Map":["1","2"]},"ConstantStringMap":{"ConstantMap":["1","2"],"Map":["1","2"]},"JSInvocationMirror":{"Invocation":[]},"NullError":{"TypeError":[],"Error":[]},"JsNoSuchMethodError":{"Error":[]},"UnknownJsTypeError":{"Error":[]},"_StackTrace":{"StackTrace":[]},"Closure":{"Function":[]},"Closure0Args":{"Function":[]},"Closure2Args":{"Function":[]},"TearOffClosure":{"Function":[]},"StaticClosure":{"Function":[]},"BoundClosure":{"Function":[]},"_CyclicInitializationError":{"Error":[]},"RuntimeError":{"Error":[]},"JsLinkedHashMap":{"MapBase":["1","2"],"Map":["1","2"],"MapBase.K":"1","MapBase.V":"2"},"LinkedHashMapKeyIterable":{"Iterable":["1"]},"NativeByteBuffer":{"JSObject":[],"TrustedGetRuntimeType":[]},"NativeTypedData":{"JSObject":[]},"NativeByteData":{"JSObject":[],"TrustedGetRuntimeType":[]},"NativeTypedArray":{"JavaScriptIndexingBehavior":["1"],"JSObject":[]},"NativeTypedArrayOfDouble":{"ListBase":["double"],"List":["double"],"JavaScriptIndexingBehavior":["double"],"JSObject":[],"Iterable":["double"],"FixedLengthListMixin":["double"]},"NativeTypedArrayOfInt":{"ListBase":["int"],"List":["int"],"JavaScriptIndexingBehavior":["int"],"JSObject":[],"Iterable":["int"],"FixedLengthListMixin":["int"]},"NativeFloat32List":{"ListBase":["double"],"List":["double"],"JavaScriptIndexingBehavior":["double"],"JSObject":[],"Iterable":["double"],"FixedLengthListMixin":["double"],"TrustedGetRuntimeType":[],"ListBase.E":"double"},"NativeFloat64List":{"ListBase":["double"],"List":["double"],"JavaScriptIndexingBehavior":["double"],"JSObject":[],"Iterable":["double"],"FixedLengthListMixin":["double"],"TrustedGetRuntimeType":[],"ListBase.E":"double"},"NativeInt16List":{"ListBase":["int"],"List":["int"],"JavaScriptIndexingBehavior":["int"],"JSObject":[],"Iterable":["int"],"FixedLengthListMixin":["int"],"TrustedGetRuntimeType":[],"ListBase.E":"int"},"NativeInt32List":{"ListBase":["int"],"List":["int"],"JavaScriptIndexingBehavior":["int"],"JSObject":[],"Iterable":["int"],"FixedLengthListMixin":["int"],"TrustedGetRuntimeType":[],"ListBase.E":"int"},"NativeInt8List":{"ListBase":["int"],"List":["int"],"JavaScriptIndexingBehavior":["int"],"JSObject":[],"Iterable":["int"],"FixedLengthListMixin":["int"],"TrustedGetRuntimeType":[],"ListBase.E":"int"},"NativeUint16List":{"ListBase":["int"],"List":["int"],"JavaScriptIndexingBehavior":["int"],"JSObject":[],"Iterable":["int"],"FixedLengthListMixin":["int"],"TrustedGetRuntimeType":[],"ListBase.E":"int"},"NativeUint32List":{"ListBase":["int"],"List":["int"],"JavaScriptIndexingBehavior":["int"],"JSObject":[],"Iterable":["int"],"FixedLengthListMixin":["int"],"TrustedGetRuntimeType":[],"ListBase.E":"int"},"NativeUint8ClampedList":{"ListBase":["int"],"List":["int"],"JavaScriptIndexingBehavior":["int"],"JSObject":[],"Iterable":["int"],"FixedLengthListMixin":["int"],"TrustedGetRuntimeType":[],"ListBase.E":"int"},"NativeUint8List":{"ListBase":["int"],"List":["int"],"JavaScriptIndexingBehavior":["int"],"JSObject":[],"Iterable":["int"],"FixedLengthListMixin":["int"],"TrustedGetRuntimeType":[],"ListBase.E":"int"},"_Error":{"Error":[]},"_TypeError":{"TypeError":[],"Error":[]},"_Future":{"Future":["1"]},"_TimerImpl":{"Timer":[]},"_AsyncAwaitCompleter":{"Completer":["1"]},"AsyncError":{"Error":[]},"_Completer":{"Completer":["1"]},"_AsyncCompleter":{"_Completer":["1"],"Completer":["1"]},"_SyncCompleter":{"_Completer":["1"],"Completer":["1"]},"_StreamController":{"StreamController":["1"],"_StreamControllerLifecycle":["1"],"_EventDispatch":["1"]},"_AsyncStreamController":{"_AsyncStreamControllerDispatch":["1"],"_StreamController":["1"],"StreamController":["1"],"_StreamControllerLifecycle":["1"],"_EventDispatch":["1"]},"_ControllerStream":{"_StreamImpl":["1"],"Stream":["1"]},"_ControllerSubscription":{"_BufferingStreamSubscription":["1"],"StreamSubscription":["1"],"_EventDispatch":["1"]},"_BufferingStreamSubscription":{"StreamSubscription":["1"],"_EventDispatch":["1"]},"_StreamImpl":{"Stream":["1"]},"_DelayedData":{"_DelayedEvent":["1"]},"_DelayedError":{"_DelayedEvent":["@"]},"_DelayedDone":{"_DelayedEvent":["@"]},"_Zone":{"Zone":[]},"_RootZone":{"_Zone":[],"Zone":[]},"_HashMap":{"MapBase":["1","2"],"Map":["1","2"]},"_IdentityHashMap":{"_HashMap":["1","2"],"MapBase":["1","2"],"Map":["1","2"],"MapBase.K":"1","MapBase.V":"2"},"_HashMapKeyIterable":{"Iterable":["1"]},"MapBase":{"Map":["1","2"]},"MapView":{"Map":["1","2"]},"UnmodifiableMapView":{"_UnmodifiableMapView_MapView__UnmodifiableMapMixin":["1","2"],"MapView":["1","2"],"_UnmodifiableMapMixin":["1","2"],"Map":["1","2"]},"ListQueue":{"Queue":["1"],"ListIterable":["1"],"Iterable":["1"],"ListIterable.E":"1"},"_JsonMap":{"MapBase":["String","@"],"Map":["String","@"],"MapBase.K":"String","MapBase.V":"@"},"_JsonMapKeyIterable":{"ListIterable":["String"],"Iterable":["String"],"ListIterable.E":"String"},"JsonUnsupportedObjectError":{"Error":[]},"JsonCyclicError":{"Error":[]},"JsonCodec":{"Codec":["Object?","String"]},"JsonEncoder":{"Converter":["Object?","String"]},"JsonDecoder":{"Converter":["String","Object?"]},"double":{"num":[]},"int":{"num":[]},"String":{"Pattern":[]},"AssertionError":{"Error":[]},"TypeError":{"Error":[]},"ArgumentError":{"Error":[]},"RangeError":{"Error":[]},"IndexError":{"Error":[]},"NoSuchMethodError":{"Error":[]},"UnsupportedError":{"Error":[]},"UnimplementedError":{"Error":[]},"StateError":{"Error":[]},"ConcurrentModificationError":{"Error":[]},"OutOfMemoryError":{"Error":[]},"StackOverflowError":{"Error":[]},"_StringStackTrace":{"StackTrace":[]},"StringBuffer":{"StringSink":[]},"_EventStream":{"Stream":["1"]},"_ElementEventStreamImpl":{"_EventStream":["1"],"Stream":["1"]},"_EventStreamSubscription":{"StreamSubscription":["1"]},"Int8List":{"List":["int"],"Iterable":["int"]},"Uint8List":{"List":["int"],"Iterable":["int"]},"Uint8ClampedList":{"List":["int"],"Iterable":["int"]},"Int16List":{"List":["int"],"Iterable":["int"]},"Uint16List":{"List":["int"],"Iterable":["int"]},"Int32List":{"List":["int"],"Iterable":["int"]},"Uint32List":{"List":["int"],"Iterable":["int"]},"Float32List":{"List":["double"],"Iterable":["double"]},"Float64List":{"List":["double"],"Iterable":["double"]}}')); + A._Universe_addErasedTypes(init.typeUniverse, JSON.parse('{"EfficientLengthIterable":1,"NativeTypedArray":1,"_DelayedEvent":1,"StreamChannelMixin":1}')); + var string$ = { + Error_: "Error handler must accept one Object or one Object and a StackTrace as arguments, and return a value of the returned future's type" + }; + var type$ = (function rtii() { + var findType = A.findType; + return { + $env_1_1_void: findType("@<~>"), + AsyncError: findType("AsyncError"), + ConstantMapView_Symbol_dynamic: findType("ConstantMapView"), + Error: findType("Error"), + Function: findType("Function"), + Future_dynamic: findType("Future<@>"), + Invocation: findType("Invocation"), + Iterable_dynamic: findType("Iterable<@>"), + JSArray_String: findType("JSArray"), + JSArray_dynamic: findType("JSArray<@>"), + JSNull: findType("JSNull"), + JSObject: findType("JSObject"), + JavaScriptFunction: findType("JavaScriptFunction"), + JavaScriptIndexingBehavior_dynamic: findType("JavaScriptIndexingBehavior<@>"), + JsLinkedHashMap_Symbol_dynamic: findType("JsLinkedHashMap"), + List_dynamic: findType("List<@>"), + Logger: findType("Logger"), + Map_dynamic_dynamic: findType("Map<@,@>"), + Null: findType("Null"), + Object: findType("Object"), + PoolResource: findType("PoolResource"), + Record: findType("Record"), + StackTrace: findType("StackTrace"), + String: findType("String"), + Symbol: findType("Symbol0"), + TrustedGetRuntimeType: findType("TrustedGetRuntimeType"), + TypeError: findType("TypeError"), + UnknownJavaScriptObject: findType("UnknownJavaScriptObject"), + _AsyncCompleter_PoolResource: findType("_AsyncCompleter"), + _AsyncCompleter_void: findType("_AsyncCompleter<~>"), + _ElementEventStreamImpl_JSObject: findType("_ElementEventStreamImpl"), + _EventStream_JSObject: findType("_EventStream"), + _Future_PoolResource: findType("_Future"), + _Future_dynamic: findType("_Future<@>"), + _Future_int: findType("_Future"), + _Future_void: findType("_Future<~>"), + _IdentityHashMap_of_nullable_Object_and_nullable_Object: findType("_IdentityHashMap"), + _StreamControllerAddStreamState_nullable_Object: findType("_StreamControllerAddStreamState"), + _SyncCompleter_PoolResource: findType("_SyncCompleter"), + bool: findType("bool"), + bool_Function_Object: findType("bool(Object)"), + double: findType("double"), + dynamic: findType("@"), + dynamic_Function: findType("@()"), + dynamic_Function_Object: findType("@(Object)"), + dynamic_Function_Object_StackTrace: findType("@(Object,StackTrace)"), + int: findType("int"), + legacy_Never: findType("0&*"), + legacy_Object: findType("Object*"), + nullable_Future_Null: findType("Future?"), + nullable_JSObject: findType("JSObject?"), + nullable_List_dynamic: findType("List<@>?"), + nullable_Object: findType("Object?"), + nullable_StackTrace: findType("StackTrace?"), + nullable__DelayedEvent_dynamic: findType("_DelayedEvent<@>?"), + nullable__FutureListener_dynamic_dynamic: findType("_FutureListener<@,@>?"), + nullable_void_Function: findType("~()?"), + nullable_void_Function_JSObject: findType("~(JSObject)?"), + num: findType("num"), + void: findType("~"), + void_Function: findType("~()"), + void_Function_Object: findType("~(Object)"), + void_Function_Object_StackTrace: findType("~(Object,StackTrace)"), + void_Function_String_dynamic: findType("~(String,@)") + }; + })(); + (function constants() { + var makeConstList = hunkHelpers.makeConstList; + B.Interceptor_methods = J.Interceptor.prototype; + B.JSArray_methods = J.JSArray.prototype; + B.JSInt_methods = J.JSInt.prototype; + B.JSNumber_methods = J.JSNumber.prototype; + B.JSString_methods = J.JSString.prototype; + B.JavaScriptFunction_methods = J.JavaScriptFunction.prototype; + B.JavaScriptObject_methods = J.JavaScriptObject.prototype; + B.PlainJavaScriptObject_methods = J.PlainJavaScriptObject.prototype; + B.UnknownJavaScriptObject_methods = J.UnknownJavaScriptObject.prototype; + B.C_JS_CONST = function getTagFallback(o) { + var s = Object.prototype.toString.call(o); + return s.substring(8, s.length - 1); +}; + B.C_JS_CONST0 = function() { + var toStringFunction = Object.prototype.toString; + function getTag(o) { + var s = toStringFunction.call(o); + return s.substring(8, s.length - 1); + } + function getUnknownTag(object, tag) { + if (/^HTML[A-Z].*Element$/.test(tag)) { + var name = toStringFunction.call(object); + if (name == "[object Object]") return null; + return "HTMLElement"; + } + } + function getUnknownTagGenericBrowser(object, tag) { + if (object instanceof HTMLElement) return "HTMLElement"; + return getUnknownTag(object, tag); + } + function prototypeForTag(tag) { + if (typeof window == "undefined") return null; + if (typeof window[tag] == "undefined") return null; + var constructor = window[tag]; + if (typeof constructor != "function") return null; + return constructor.prototype; + } + function discriminator(tag) { return null; } + var isBrowser = typeof HTMLElement == "function"; + return { + getTag: getTag, + getUnknownTag: isBrowser ? getUnknownTagGenericBrowser : getUnknownTag, + prototypeForTag: prototypeForTag, + discriminator: discriminator }; +}; + B.C_JS_CONST6 = function(getTagFallback) { + return function(hooks) { + if (typeof navigator != "object") return hooks; + var userAgent = navigator.userAgent; + if (typeof userAgent != "string") return hooks; + if (userAgent.indexOf("DumpRenderTree") >= 0) return hooks; + if (userAgent.indexOf("Chrome") >= 0) { + function confirm(p) { + return typeof window == "object" && window[p] && window[p].name == p; + } + if (confirm("Window") && confirm("HTMLElement")) return hooks; + } + hooks.getTag = getTagFallback; + }; +}; + B.C_JS_CONST1 = function(hooks) { + if (typeof dartExperimentalFixupGetTag != "function") return hooks; + hooks.getTag = dartExperimentalFixupGetTag(hooks.getTag); +}; + B.C_JS_CONST5 = function(hooks) { + if (typeof navigator != "object") return hooks; + var userAgent = navigator.userAgent; + if (typeof userAgent != "string") return hooks; + if (userAgent.indexOf("Firefox") == -1) return hooks; + var getTag = hooks.getTag; + var quickMap = { + "BeforeUnloadEvent": "Event", + "DataTransfer": "Clipboard", + "GeoGeolocation": "Geolocation", + "Location": "!Location", + "WorkerMessageEvent": "MessageEvent", + "XMLDocument": "!Document"}; + function getTagFirefox(o) { + var tag = getTag(o); + return quickMap[tag] || tag; + } + hooks.getTag = getTagFirefox; +}; + B.C_JS_CONST4 = function(hooks) { + if (typeof navigator != "object") return hooks; + var userAgent = navigator.userAgent; + if (typeof userAgent != "string") return hooks; + if (userAgent.indexOf("Trident/") == -1) return hooks; + var getTag = hooks.getTag; + var quickMap = { + "BeforeUnloadEvent": "Event", + "DataTransfer": "Clipboard", + "HTMLDDElement": "HTMLElement", + "HTMLDTElement": "HTMLElement", + "HTMLPhraseElement": "HTMLElement", + "Position": "Geoposition" + }; + function getTagIE(o) { + var tag = getTag(o); + var newTag = quickMap[tag]; + if (newTag) return newTag; + if (tag == "Object") { + if (window.DataView && (o instanceof window.DataView)) return "DataView"; + } + return tag; + } + function prototypeForTagIE(tag) { + var constructor = window[tag]; + if (constructor == null) return null; + return constructor.prototype; + } + hooks.getTag = getTagIE; + hooks.prototypeForTag = prototypeForTagIE; +}; + B.C_JS_CONST2 = function(hooks) { + var getTag = hooks.getTag; + var prototypeForTag = hooks.prototypeForTag; + function getTagFixed(o) { + var tag = getTag(o); + if (tag == "Document") { + if (!!o.xmlVersion) return "!Document"; + return "!HTMLDocument"; + } + return tag; + } + function prototypeForTagFixed(tag) { + if (tag == "Document") return null; + return prototypeForTag(tag); + } + hooks.getTag = getTagFixed; + hooks.prototypeForTag = prototypeForTagFixed; +}; + B.C_JS_CONST3 = function(hooks) { return hooks; } +; + B.C_JsonCodec = new A.JsonCodec(); + B.C_OutOfMemoryError = new A.OutOfMemoryError(); + B.C__DelayedDone = new A._DelayedDone(); + B.C__JSRandom = new A._JSRandom(); + B.C__Required = new A._Required(); + B.C__RootZone = new A._RootZone(); + B.Duration_0 = new A.Duration(0); + B.Duration_5000000 = new A.Duration(5000000); + B.JsonDecoder_null = new A.JsonDecoder(null); + B.JsonEncoder_null = new A.JsonEncoder(null); + B.Level_INFO_800 = new A.Level("INFO", 800); + B.Level_SEVERE_1000 = new A.Level("SEVERE", 1000); + B.Level_WARNING_900 = new A.Level("WARNING", 900); + B.List_empty = A._setArrayType(makeConstList([]), type$.JSArray_dynamic); + B.Object_empty = {}; + B.Map_empty = new A.ConstantStringMap(B.Object_empty, [], A.findType("ConstantStringMap")); + B.Symbol_call = new A.Symbol("call"); + B.Type_ByteBuffer_RkP = A.typeLiteral("ByteBuffer"); + B.Type_ByteData_zNC = A.typeLiteral("ByteData"); + B.Type_Float32List_LB7 = A.typeLiteral("Float32List"); + B.Type_Float64List_LB7 = A.typeLiteral("Float64List"); + B.Type_Int16List_uXf = A.typeLiteral("Int16List"); + B.Type_Int32List_O50 = A.typeLiteral("Int32List"); + B.Type_Int8List_ekJ = A.typeLiteral("Int8List"); + B.Type_Uint16List_2bx = A.typeLiteral("Uint16List"); + B.Type_Uint32List_2bx = A.typeLiteral("Uint32List"); + B.Type_Uint8ClampedList_Jik = A.typeLiteral("Uint8ClampedList"); + B.Type_Uint8List_WLA = A.typeLiteral("Uint8List"); + B._StringStackTrace_3uE = new A._StringStackTrace(""); + })(); + (function staticFields() { + $._JS_INTEROP_INTERCEPTOR_TAG = null; + $.toStringVisiting = A._setArrayType([], A.findType("JSArray")); + $.Primitives__identityHashCodeProperty = null; + $.BoundClosure__receiverFieldNameCache = null; + $.BoundClosure__interceptorFieldNameCache = null; + $.getTagFunction = null; + $.alternateTagFunction = null; + $.prototypeForTagFunction = null; + $.dispatchRecordsForInstanceTags = null; + $.interceptorsForUncacheableTags = null; + $.initNativeDispatchFlag = null; + $._nextCallback = null; + $._lastCallback = null; + $._lastPriorityCallback = null; + $._isInCallbackLoop = false; + $.Zone__current = B.C__RootZone; + $.LogRecord__nextNumber = 0; + $.Logger__loggers = A.LinkedHashMap_LinkedHashMap$_empty(type$.String, type$.Logger); + })(); + (function lazyInitializers() { + var _lazyFinal = hunkHelpers.lazyFinal; + _lazyFinal($, "DART_CLOSURE_PROPERTY_NAME", "$get$DART_CLOSURE_PROPERTY_NAME", () => A.getIsolateAffinityTag("_$dart_dartClosure")); + _lazyFinal($, "nullFuture", "$get$nullFuture", () => B.C__RootZone.run$1$1(new A.nullFuture_closure(), A.findType("Future"))); + _lazyFinal($, "TypeErrorDecoder_noSuchMethodPattern", "$get$TypeErrorDecoder_noSuchMethodPattern", () => A.TypeErrorDecoder_extractPattern(A.TypeErrorDecoder_provokeCallErrorOn({ + toString: function() { + return "$receiver$"; + } + }))); + _lazyFinal($, "TypeErrorDecoder_notClosurePattern", "$get$TypeErrorDecoder_notClosurePattern", () => A.TypeErrorDecoder_extractPattern(A.TypeErrorDecoder_provokeCallErrorOn({$method$: null, + toString: function() { + return "$receiver$"; + } + }))); + _lazyFinal($, "TypeErrorDecoder_nullCallPattern", "$get$TypeErrorDecoder_nullCallPattern", () => A.TypeErrorDecoder_extractPattern(A.TypeErrorDecoder_provokeCallErrorOn(null))); + _lazyFinal($, "TypeErrorDecoder_nullLiteralCallPattern", "$get$TypeErrorDecoder_nullLiteralCallPattern", () => A.TypeErrorDecoder_extractPattern(function() { + var $argumentsExpr$ = "$arguments$"; + try { + null.$method$($argumentsExpr$); + } catch (e) { + return e.message; + } + }())); + _lazyFinal($, "TypeErrorDecoder_undefinedCallPattern", "$get$TypeErrorDecoder_undefinedCallPattern", () => A.TypeErrorDecoder_extractPattern(A.TypeErrorDecoder_provokeCallErrorOn(void 0))); + _lazyFinal($, "TypeErrorDecoder_undefinedLiteralCallPattern", "$get$TypeErrorDecoder_undefinedLiteralCallPattern", () => A.TypeErrorDecoder_extractPattern(function() { + var $argumentsExpr$ = "$arguments$"; + try { + (void 0).$method$($argumentsExpr$); + } catch (e) { + return e.message; + } + }())); + _lazyFinal($, "TypeErrorDecoder_nullPropertyPattern", "$get$TypeErrorDecoder_nullPropertyPattern", () => A.TypeErrorDecoder_extractPattern(A.TypeErrorDecoder_provokePropertyErrorOn(null))); + _lazyFinal($, "TypeErrorDecoder_nullLiteralPropertyPattern", "$get$TypeErrorDecoder_nullLiteralPropertyPattern", () => A.TypeErrorDecoder_extractPattern(function() { + try { + null.$method$; + } catch (e) { + return e.message; + } + }())); + _lazyFinal($, "TypeErrorDecoder_undefinedPropertyPattern", "$get$TypeErrorDecoder_undefinedPropertyPattern", () => A.TypeErrorDecoder_extractPattern(A.TypeErrorDecoder_provokePropertyErrorOn(void 0))); + _lazyFinal($, "TypeErrorDecoder_undefinedLiteralPropertyPattern", "$get$TypeErrorDecoder_undefinedLiteralPropertyPattern", () => A.TypeErrorDecoder_extractPattern(function() { + try { + (void 0).$method$; + } catch (e) { + return e.message; + } + }())); + _lazyFinal($, "_AsyncRun__scheduleImmediateClosure", "$get$_AsyncRun__scheduleImmediateClosure", () => A._AsyncRun__initializeScheduleImmediate()); + _lazyFinal($, "Future__nullFuture", "$get$Future__nullFuture", () => A.findType("_Future")._as($.$get$nullFuture())); + _lazyFinal($, "Logger_root", "$get$Logger_root", () => A.Logger_Logger("")); + _lazyFinal($, "_requestPool", "$get$_requestPool", () => { + var t4, + t1 = A.findType("Completer"), + t2 = A.ListQueue$(t1), + t3 = A.ListQueue$(type$.void_Function); + t1 = A.ListQueue$(t1); + t4 = A.Completer_Completer(type$.dynamic); + return new A.Pool(t2, t3, t1, 1000, new A.AsyncMemoizer(t4, A.findType("AsyncMemoizer<@>"))); + }); + })(); + (function nativeSupport() { + !function() { + var intern = function(s) { + var o = {}; + o[s] = 1; + return Object.keys(hunkHelpers.convertToFastObject(o))[0]; + }; + init.getIsolateTag = function(name) { + return intern("___dart_" + name + init.isolateTag); + }; + var tableProperty = "___dart_isolate_tags_"; + var usedProperties = Object[tableProperty] || (Object[tableProperty] = Object.create(null)); + var rootProperty = "_ZxYxX"; + for (var i = 0;; i++) { + var property = intern(rootProperty + "_" + i + "_"); + if (!(property in usedProperties)) { + usedProperties[property] = 1; + init.isolateTag = property; + break; + } + } + init.dispatchPropertyName = init.getIsolateTag("dispatch_record"); + }(); + hunkHelpers.setOrUpdateInterceptorsByTag({ArrayBuffer: A.NativeByteBuffer, ArrayBufferView: A.NativeTypedData, DataView: A.NativeByteData, Float32Array: A.NativeFloat32List, Float64Array: A.NativeFloat64List, Int16Array: A.NativeInt16List, Int32Array: A.NativeInt32List, Int8Array: A.NativeInt8List, Uint16Array: A.NativeUint16List, Uint32Array: A.NativeUint32List, Uint8ClampedArray: A.NativeUint8ClampedList, CanvasPixelArray: A.NativeUint8ClampedList, Uint8Array: A.NativeUint8List}); + hunkHelpers.setOrUpdateLeafTags({ArrayBuffer: true, ArrayBufferView: false, DataView: true, Float32Array: true, Float64Array: true, Int16Array: true, Int32Array: true, Int8Array: true, Uint16Array: true, Uint32Array: true, Uint8ClampedArray: true, CanvasPixelArray: true, Uint8Array: false}); + A.NativeTypedArray.$nativeSuperclassTag = "ArrayBufferView"; + A._NativeTypedArrayOfDouble_NativeTypedArray_ListMixin.$nativeSuperclassTag = "ArrayBufferView"; + A._NativeTypedArrayOfDouble_NativeTypedArray_ListMixin_FixedLengthListMixin.$nativeSuperclassTag = "ArrayBufferView"; + A.NativeTypedArrayOfDouble.$nativeSuperclassTag = "ArrayBufferView"; + A._NativeTypedArrayOfInt_NativeTypedArray_ListMixin.$nativeSuperclassTag = "ArrayBufferView"; + A._NativeTypedArrayOfInt_NativeTypedArray_ListMixin_FixedLengthListMixin.$nativeSuperclassTag = "ArrayBufferView"; + A.NativeTypedArrayOfInt.$nativeSuperclassTag = "ArrayBufferView"; + })(); + Function.prototype.call$1 = function(a) { + return this(a); + }; + Function.prototype.call$0 = function() { + return this(); + }; + Function.prototype.call$2 = function(a, b) { + return this(a, b); + }; + Function.prototype.call$3 = function(a, b, c) { + return this(a, b, c); + }; + Function.prototype.call$4 = function(a, b, c, d) { + return this(a, b, c, d); + }; + Function.prototype.call$1$1 = function(a) { + return this(a); + }; + convertAllToFastObject(holders); + convertToFastObject($); + (function(callback) { + if (typeof document === "undefined") { + callback(null); + return; + } + if (typeof document.currentScript != "undefined") { + callback(document.currentScript); + return; + } + var scripts = document.scripts; + function onLoad(event) { + for (var i = 0; i < scripts.length; ++i) { + scripts[i].removeEventListener("load", onLoad, false); + } + callback(event.target); + } + for (var i = 0; i < scripts.length; ++i) { + scripts[i].addEventListener("load", onLoad, false); + } + })(function(currentScript) { + init.currentScript = currentScript; + var callMain = A.main; + if (typeof dartMainRunner === "function") { + dartMainRunner(callMain, []); + } else { + callMain([]); + } + }); +})(); diff --git a/pkgs/sse/test/web/index.html b/pkgs/sse/test/web/index.html new file mode 100644 index 000000000..be2676311 --- /dev/null +++ b/pkgs/sse/test/web/index.html @@ -0,0 +1,13 @@ + + + + + SSE Broadcast Channel Test + + + + + + + + diff --git a/pkgs/sse/tool/build_js.sh b/pkgs/sse/tool/build_js.sh new file mode 100755 index 000000000..ef29b706c --- /dev/null +++ b/pkgs/sse/tool/build_js.sh @@ -0,0 +1,2 @@ +#!/bin/bash +dart compile js --no-source-maps test/web/index.dart -o test/web/index.dart.js diff --git a/pkgs/stack_trace/.gitignore b/pkgs/stack_trace/.gitignore new file mode 100644 index 000000000..f0230158a --- /dev/null +++ b/pkgs/stack_trace/.gitignore @@ -0,0 +1,6 @@ +# See https://dart.dev/guides/libraries/private-files +# Don’t commit the following directories created by pub. +.dart_tool/ +.packages +.pub/ +pubspec.lock diff --git a/pkgs/stack_trace/CHANGELOG.md b/pkgs/stack_trace/CHANGELOG.md new file mode 100644 index 000000000..e92cf9cea --- /dev/null +++ b/pkgs/stack_trace/CHANGELOG.md @@ -0,0 +1,363 @@ +## 1.12.1 + +* Move to `dart-lang/tools` monorepo. + +## 1.12.0 + +* Added support for parsing Wasm frames of Chrome (V8), Firefox, Safari. +* Require Dart 3.4 or greater + +## 1.11.1 + +* Make use of `@pragma('vm:awaiter-link')` to make package work better with + Dart VM's builtin awaiter stack unwinding. No other changes. + +## 1.11.0 + +* Added the parameter `zoneValues` to `Chain.capture` to be able to use custom + zone values with the `runZoned` internal calls. +* Populate the pubspec `repository` field. +* Require Dart 2.18 or greater + +## 1.10.0 + +* Stable release for null safety. +* Fix broken test, `test/chain/vm_test.dart`, which incorrectly handles + asynchronous suspension gap markers at the end of stack traces. + +## 1.10.0-nullsafety.6 + +* Fix bug parsing asynchronous suspension gap markers at the end of stack + traces, when parsing with `Trace.parse` and `Chain.parse`. +* Update SDK constraints to `>=2.12.0-0 <3.0.0` based on beta release + guidelines. + +## 1.10.0-nullsafety.5 + +* Allow prerelease versions of the 2.12 sdk. + +## 1.10.0-nullsafety.4 + +* Allow the `2.10.0` stable and dev SDKs. + +## 1.10.0-nullsafety.3 + +* Fix bug parsing asynchronous suspension gap markers at the end of stack + traces. + +## 1.10.0-nullsafety.2 + +* Forward fix for a change in SDK type promotion behavior. + +## 1.10.0-nullsafety.1 + +* Allow 2.10 stable and 2.11.0 dev SDK versions. + +## 1.10.0-nullsafety + +* Opt in to null safety. + +## 1.9.6 (backpublish) + +* Fix bug parsing asynchronous suspension gap markers at the end of stack + traces. (Also fixed separately in 1.10.0-nullsafety.3) +* Fix bug parsing asynchronous suspension gap markers at the end of stack + traces, when parsing with `Trace.parse` and `Chain.parse`. (Also fixed + separately in 1.10.0-nullsafety.6) + +## 1.9.5 + +* Parse the format for `data:` URIs that the Dart VM has used since `2.2.0`. + +## 1.9.4 + +* Add support for firefox anonymous stack traces. +* Add support for chrome eval stack traces without a column. +* Change the argument type to `Chain.capture` from `Function(dynamic, Chain)` to + `Function(Object, Chain)`. Existing functions which take `dynamic` are still + fine, but new uses can have a safer type. + +## 1.9.3 + +* Set max SDK version to `<3.0.0`. + +## 1.9.2 + +* Fix Dart 2.0 runtime cast failure in test. + +## 1.9.1 + +* Preserve the original chain for a trace to handle cases where an + error is rethrown. + +## 1.9.0 + +* Add an `errorZone` parameter to `Chain.capture()` that makes it avoid creating + an error zone. + +## 1.8.3 + +* `Chain.forTrace()` now returns a full stack chain for *all* `StackTrace`s + within `Chain.capture()`, even those that haven't been processed by + `dart:async` yet. + +* `Chain.forTrace()` now uses the Dart VM's stack chain information when called + synchronously within `Chain.capture()`. This matches the existing behavior + outside `Chain.capture()`. + +* `Chain.forTrace()` now trims the VM's stack chains for the innermost stack + trace within `Chain.capture()` (unless it's called synchronously, as above). + This avoids duplicated frames and makes the format of the innermost traces + consistent with the other traces in the chain. + +## 1.8.2 + +* Update to use strong-mode clean Zone API. + +## 1.8.1 + +* Use official generic function syntax. + +* Updated minimum SDK to 1.23.0. + +## 1.8.0 + +* Add a `Trace.original` field to provide access to the original `StackTrace`s + from which the `Trace` was created, and a matching constructor parameter to + `new Trace()`. + +## 1.7.4 + +* Always run `onError` callbacks for `Chain.capture()` in the parent zone. + +## 1.7.3 + +* Fix broken links in the README. + +## 1.7.2 + +* `Trace.foldFrames()` and `Chain.foldFrames()` now remove the outermost folded + frame. This matches the behavior of `.terse` with core frames. + +* Fix bug parsing a friendly frame with spaces in the member name. + +* Fix bug parsing a friendly frame where the location is a data url. + +## 1.7.1 + +* Make `Trace.parse()`, `Chain.parse()`, treat the VM's new causal asynchronous + stack traces as chains. Outside of a `Chain.capture()` block, `new + Chain.current()` will return a stack chain constructed from the asynchronous + stack traces. + +## 1.7.0 + +* Add a `Chain.disable()` function that disables stack-chain tracking. + +* Fix a bug where `Chain.capture(..., when: false)` would throw if an error was + emitted without a stack trace. + +## 1.6.8 + +* Add a note to the documentation of `Chain.terse` and `Trace.terse`. + +## 1.6.7 + +* Fix a bug where `new Frame.caller()` returned the wrong depth of frame on + Dartium. + +## 1.6.6 + +* `new Trace.current()` and `new Chain.current()` now skip an extra frame when + run in a JS context. This makes their return values match the VM context. + +## 1.6.5 + +* Really fix strong mode warnings. + +## 1.6.4 + +* Fix a syntax error introduced in 1.6.3. + +## 1.6.3 + +* Make `Chain.capture()` generic. Its signature is now `T Chain.capture(T + callback(), ...)`. + +## 1.6.2 + +* Fix all strong mode warnings. + +## 1.6.1 + +* Use `StackTrace.current` in Dart SDK 1.14 to get the current stack trace. + +## 1.6.0 + +* Add a `when` parameter to `Chain.capture()`. This allows capturing to be + easily enabled and disabled based on whether the application is running in + debug/development mode or not. + +* Deprecate the `ChainHandler` typedef. This didn't provide any value over + directly annotating the function argument, and it made the documentation less + clear. + +## 1.5.1 + +* Fix a crash in `Chain.foldFrames()` and `Chain.terse` when one of the chain's + traces has no frames. + +## 1.5.0 + +* `new Chain.parse()` now parses all the stack trace formats supported by `new + Trace.parse()`. Formats other than that emitted by `Chain.toString()` will + produce single-element chains. + +* `new Trace.parse()` now parses the output of `Chain.toString()`. It produces + the same result as `Chain.parse().toTrace()`. + +## 1.4.2 + +* Improve the display of `data:` URIs in stack traces. + +## 1.4.1 + +* Fix a crashing bug in `UnparsedFrame.toString()`. + +## 1.4.0 + +* `new Trace.parse()` and related constructors will no longer throw an exception + if they encounter an unparseable stack frame. Instead, they will generate an + `UnparsedFrame`, which exposes no metadata but preserves the frame's original + text. + +* Properly parse native-code V8 frames. + +## 1.3.5 + +* Properly shorten library names for pathnames of folded frames on Windows. + +## 1.3.4 + +* No longer say that stack chains aren't supported on dart2js now that + [sdk#15171][] is fixed. Note that this fix only applies to Dart 1.12. + +[sdk#15171]: https://github.com/dart-lang/sdk/issues/15171 + +## 1.3.3 + +* When a `null` stack trace is passed to a completer or stream controller in + nested `Chain.capture()` blocks, substitute the inner block's chain rather + than the outer block's. + +* Add support for empty chains and chains of empty traces to `Chain.parse()`. + +* Don't crash when parsing stack traces from Dart VM stack overflows. + +## 1.3.2 + +* Don't crash when running `Trace.terse` on empty stack traces. + +## 1.3.1 + +* Support more types of JavaScriptCore stack frames. + +## 1.3.0 + +* Support stack traces generated by JavaScriptCore. They can be explicitly + parsed via `new Trace.parseJSCore` and `new Frame.parseJSCore`. + +## 1.2.4 + +* Fix a type annotation in `LazyTrace`. + +## 1.2.3 + +* Fix a crash in `Chain.parse`. + +## 1.2.2 + +* Don't print the first folded frame of terse stack traces. This frame + is always just an internal isolate message handler anyway. This + improves the readability of stack traces, especially in stack chains. + +* Remove the line numbers and specific files in all terse folded frames, not + just those from core libraries. + +* Make padding consistent across all stack traces for `Chain.toString()`. + +## 1.2.1 + +* Add `terse` to `LazyTrace.foldFrames()`. + +* Further improve stack chains when using the VM's async/await implementation. + +## 1.2.0 + +* Add a `terse` argument to `Trace.foldFrames()` and `Chain.foldFrames()`. This + allows them to inherit the behavior of `Trace.terse` and `Chain.terse` without + having to duplicate the logic. + +## 1.1.3 + +* Produce nicer-looking stack chains when using the VM's async/await + implementation. + +## 1.1.2 + +* Support VM frames without line *or* column numbers, which async/await programs + occasionally generate. + +* Replace `<_async_body>` in VM frames' members with the + terser ``. + +## 1.1.1 + +* Widen the SDK constraint to include 1.7.0-dev.4.0. + +## 1.1.0 + +* Unify the parsing of Safari and Firefox stack traces. This fixes an error in + Firefox trace parsing. + +* Deprecate `Trace.parseSafari6_0`, `Trace.parseSafari6_1`, + `Frame.parseSafari6_0`, and `Frame.parseSafari6_1`. + +* Add `Frame.parseSafari`. + +## 1.0.3 + +* Use `Zone.errorCallback` to attach stack chains to all errors without the need + for `Chain.track`, which is now deprecated. + +## 1.0.2 + +* Remove a workaround for [issue 17083][]. + +[issue 17083]: https://github.com/dart-lang/sdk/issues/17083 + +## 1.0.1 + +* Synchronous errors in the [Chain.capture] callback are now handled correctly. + +## 1.0.0 + +* No API changes, just declared stable. + +## 0.9.3+2 + +* Update the dependency on path. + +* Improve the formatting of library URIs in stack traces. + +## 0.9.3+1 + +* If an error is thrown in `Chain.capture`'s `onError` handler, that error is + handled by the parent zone. This matches the behavior of `runZoned` in + `dart:async`. + +## 0.9.3 + +* Add a `Chain.foldFrames` method that parallels `Trace.foldFrames`. + +* Record anonymous method frames in IE10 as "". diff --git a/pkgs/stack_trace/LICENSE b/pkgs/stack_trace/LICENSE new file mode 100644 index 000000000..162572a44 --- /dev/null +++ b/pkgs/stack_trace/LICENSE @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/stack_trace/README.md b/pkgs/stack_trace/README.md new file mode 100644 index 000000000..b10a55638 --- /dev/null +++ b/pkgs/stack_trace/README.md @@ -0,0 +1,169 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/stack_trace.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/stack_trace.yaml) +[![pub package](https://img.shields.io/pub/v/stack_trace.svg)](https://pub.dev/packages/stack_trace) +[![package publisher](https://img.shields.io/pub/publisher/stack_trace.svg)](https://pub.dev/packages/stack_trace/publisher) + +This library provides the ability to parse, inspect, and manipulate stack traces +produced by the underlying Dart implementation. It also provides functions to +produce string representations of stack traces in a more readable format than +the native [StackTrace] implementation. + +`Trace`s can be parsed from native [StackTrace]s using `Trace.from`, or captured +using `Trace.current`. Native [StackTrace]s can also be directly converted to +human-readable strings using `Trace.format`. + +[StackTrace]: https://api.dart.dev/stable/dart-core/StackTrace-class.html + +Here's an example native stack trace from debugging this library: + + #0 Object.noSuchMethod (dart:core-patch:1884:25) + #1 Trace.terse. (file:///usr/local/google-old/home/goog/dart/dart/pkg/stack_trace/lib/src/trace.dart:47:21) + #2 IterableMixinWorkaround.reduce (dart:collection:29:29) + #3 List.reduce (dart:core-patch:1247:42) + #4 Trace.terse (file:///usr/local/google-old/home/goog/dart/dart/pkg/stack_trace/lib/src/trace.dart:40:35) + #5 format (file:///usr/local/google-old/home/goog/dart/dart/pkg/stack_trace/lib/stack_trace.dart:24:28) + #6 main. (file:///usr/local/google-old/home/goog/dart/dart/test.dart:21:29) + #7 _CatchErrorFuture._sendError (dart:async:525:24) + #8 _FutureImpl._setErrorWithoutAsyncTrace (dart:async:393:26) + #9 _FutureImpl._setError (dart:async:378:31) + #10 _ThenFuture._sendValue (dart:async:490:16) + #11 _FutureImpl._handleValue. (dart:async:349:28) + #12 Timer.run. (dart:async:2402:21) + #13 Timer.Timer. (dart:async-patch:15:15) + +and its human-readable representation: + + dart:core-patch 1884:25 Object.noSuchMethod + pkg/stack_trace/lib/src/trace.dart 47:21 Trace.terse. + dart:collection 29:29 IterableMixinWorkaround.reduce + dart:core-patch 1247:42 List.reduce + pkg/stack_trace/lib/src/trace.dart 40:35 Trace.terse + pkg/stack_trace/lib/stack_trace.dart 24:28 format + test.dart 21:29 main. + dart:async 525:24 _CatchErrorFuture._sendError + dart:async 393:26 _FutureImpl._setErrorWithoutAsyncTrace + dart:async 378:31 _FutureImpl._setError + dart:async 490:16 _ThenFuture._sendValue + dart:async 349:28 _FutureImpl._handleValue. + dart:async 2402:21 Timer.run. + dart:async-patch 15:15 Timer.Timer. + +You can further clean up the stack trace using `Trace.terse`. This folds +together multiple stack frames from the Dart core libraries, so that only the +core library method that was directly called from user code is visible. For +example: + + dart:core Object.noSuchMethod + pkg/stack_trace/lib/src/trace.dart 47:21 Trace.terse. + dart:core List.reduce + pkg/stack_trace/lib/src/trace.dart 40:35 Trace.terse + pkg/stack_trace/lib/stack_trace.dart 24:28 format + test.dart 21:29 main. + +## Stack Chains + +This library also provides the ability to capture "stack chains" with the +`Chain` class. When writing asynchronous code, a single stack trace isn't very +useful, since the call stack is unwound every time something async happens. A +stack chain tracks stack traces through asynchronous calls, so that you can see +the full path from `main` down to the error. + +To use stack chains, just wrap the code that you want to track in +`Chain.capture`. This will create a new [Zone][] in which stack traces are +recorded and woven into chains every time an asynchronous call occurs. Zones are +sticky, too, so any asynchronous operations started in the `Chain.capture` +callback will have their chains tracked, as will asynchronous operations they +start and so on. + +Here's an example of some code that doesn't capture its stack chains: + +```dart +import 'dart:async'; + +void main() { + _scheduleAsync(); +} + +void _scheduleAsync() { + Future.delayed(Duration(seconds: 1)).then((_) => _runAsync()); +} + +void _runAsync() { + throw 'oh no!'; +} +``` + +If we run this, it prints the following: + + Unhandled exception: + oh no! + #0 _runAsync (file:///Users/kevmoo/github/stack_trace/example/example.dart:12:3) + #1 _scheduleAsync. (file:///Users/kevmoo/github/stack_trace/example/example.dart:8:52) + + +Notice how there's no mention of `main` in that stack trace. All we know is that +the error was in `runAsync`; we don't know why `runAsync` was called. + +Now let's look at the same code with stack chains captured: + +```dart +import 'dart:async'; + +import 'package:stack_trace/stack_trace.dart'; + +void main() { + Chain.capture(_scheduleAsync); +} + +void _scheduleAsync() { + Future.delayed(Duration(seconds: 1)).then((_) => _runAsync()); +} + +void _runAsync() { + throw 'oh no!'; +} +``` + +Now if we run it, it prints this: + + Unhandled exception: + oh no! + example/example.dart 14:3 _runAsync + example/example.dart 10:52 _scheduleAsync. + package:stack_trace/src/stack_zone_specification.dart 126:26 StackZoneSpecification._registerUnaryCallback.. + package:stack_trace/src/stack_zone_specification.dart 208:15 StackZoneSpecification._run + package:stack_trace/src/stack_zone_specification.dart 126:14 StackZoneSpecification._registerUnaryCallback. + dart:async/zone.dart 1406:47 _rootRunUnary + dart:async/zone.dart 1307:19 _CustomZone.runUnary + ===== asynchronous gap =========================== + dart:async/zone.dart 1328:19 _CustomZone.registerUnaryCallback + dart:async/future_impl.dart 315:23 Future.then + example/example.dart 10:40 _scheduleAsync + package:stack_trace/src/chain.dart 97:24 Chain.capture. + dart:async/zone.dart 1398:13 _rootRun + dart:async/zone.dart 1300:19 _CustomZone.run + dart:async/zone.dart 1803:10 _runZoned + dart:async/zone.dart 1746:10 runZoned + package:stack_trace/src/chain.dart 95:12 Chain.capture + example/example.dart 6:9 main + dart:isolate-patch/isolate_patch.dart 297:19 _delayEntrypointInvocation. + dart:isolate-patch/isolate_patch.dart 192:12 _RawReceivePortImpl._handleMessage + +That's a lot of text! If you look closely, though, you can see that `main` is +listed in the first trace in the chain. + +Thankfully, you can call `Chain.terse` just like `Trace.terse` to get rid of all +the frames you don't care about. The terse version of the stack chain above is +this: + + test.dart 17:3 runAsync + test.dart 13:28 scheduleAsync. + ===== asynchronous gap =========================== + dart:async _Future.then + test.dart 13:12 scheduleAsync + test.dart 7:18 main. + package:stack_trace Chain.capture + test.dart 6:16 main + +That's a lot easier to understand! + +[Zone]: https://api.dart.dev/stable/dart-async/Zone-class.html diff --git a/pkgs/stack_trace/analysis_options.yaml b/pkgs/stack_trace/analysis_options.yaml new file mode 100644 index 000000000..4eb82ceca --- /dev/null +++ b/pkgs/stack_trace/analysis_options.yaml @@ -0,0 +1,22 @@ +# https://dart.dev/tools/analysis#the-analysis-options-file +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-raw-types: true + +linter: + rules: + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_unused_constructor_parameters + - avoid_void_async + - cancel_subscriptions + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_runtimeType_toString + - prefer_const_declarations + - unnecessary_await_in_return + - use_string_buffers diff --git a/pkgs/stack_trace/example/example.dart b/pkgs/stack_trace/example/example.dart new file mode 100644 index 000000000..d601ca441 --- /dev/null +++ b/pkgs/stack_trace/example/example.dart @@ -0,0 +1,15 @@ +import 'dart:async'; + +import 'package:stack_trace/stack_trace.dart'; + +void main() { + Chain.capture(_scheduleAsync); +} + +void _scheduleAsync() { + Future.delayed(const Duration(seconds: 1)).then((_) => _runAsync()); +} + +void _runAsync() { + throw StateError('oh no!'); +} diff --git a/pkgs/stack_trace/lib/src/chain.dart b/pkgs/stack_trace/lib/src/chain.dart new file mode 100644 index 000000000..6a815c6bc --- /dev/null +++ b/pkgs/stack_trace/lib/src/chain.dart @@ -0,0 +1,264 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:math' as math; + +import 'frame.dart'; +import 'lazy_chain.dart'; +import 'stack_zone_specification.dart'; +import 'trace.dart'; +import 'utils.dart'; + +/// A function that handles errors in the zone wrapped by [Chain.capture]. +@Deprecated('Will be removed in stack_trace 2.0.0.') +typedef ChainHandler = void Function(dynamic error, Chain chain); + +/// An opaque key used to track the current [StackZoneSpecification]. +final _specKey = Object(); + +/// A chain of stack traces. +/// +/// A stack chain is a collection of one or more stack traces that collectively +/// represent the path from `main` through nested function calls to a particular +/// code location, usually where an error was thrown. Multiple stack traces are +/// necessary when using asynchronous functions, since the program's stack is +/// reset before each asynchronous callback is run. +/// +/// Stack chains can be automatically tracked using [Chain.capture]. This sets +/// up a new [Zone] in which the current stack chain is tracked and can be +/// accessed using [Chain.current]. Any errors that would be top-leveled in +/// the zone can be handled, along with their associated chains, with the +/// `onError` callback. For example: +/// +/// Chain.capture(() { +/// // ... +/// }, onError: (error, stackChain) { +/// print("Caught error $error\n" +/// "$stackChain"); +/// }); +class Chain implements StackTrace { + /// The stack traces that make up this chain. + /// + /// Like the frames in a stack trace, the traces are ordered from most local + /// to least local. The first one is the trace where the actual exception was + /// raised, the second one is where that callback was scheduled, and so on. + final List traces; + + /// The [StackZoneSpecification] for the current zone. + static StackZoneSpecification? get _currentSpec => + Zone.current[_specKey] as StackZoneSpecification?; + + /// If [when] is `true`, runs [callback] in a [Zone] in which the current + /// stack chain is tracked and automatically associated with (most) errors. + /// + /// If [when] is `false`, this does not track stack chains. Instead, it's + /// identical to [runZoned], except that it wraps any errors in + /// [Chain.forTrace]—which will only wrap the trace unless there's a different + /// [Chain.capture] active. This makes it easy for the caller to only capture + /// stack chains in debug mode or during development. + /// + /// If [onError] is passed, any error in the zone that would otherwise go + /// unhandled is passed to it, along with the [Chain] associated with that + /// error. Note that if [callback] produces multiple unhandled errors, + /// [onError] may be called more than once. If [onError] isn't passed, the + /// parent Zone's `unhandledErrorHandler` will be called with the error and + /// its chain. + /// + /// The zone this creates will be an error zone if either [onError] is + /// not `null` and [when] is false, + /// or if both [when] and [errorZone] are `true`. + /// If [errorZone] is `false`, [onError] must be `null`. + /// + /// If [callback] returns a value, it will be returned by [capture] as well. + /// + /// [zoneValues] is added to the [runZoned] calls. + static T capture(T Function() callback, + {void Function(Object error, Chain)? onError, + bool when = true, + bool errorZone = true, + Map? zoneValues}) { + if (!errorZone && onError != null) { + throw ArgumentError.value( + onError, 'onError', 'must be null if errorZone is false'); + } + + if (!when) { + if (onError == null) return runZoned(callback, zoneValues: zoneValues); + return runZonedGuarded(callback, (error, stackTrace) { + onError(error, Chain.forTrace(stackTrace)); + }, zoneValues: zoneValues) as T; + } + + var spec = StackZoneSpecification(onError, errorZone: errorZone); + return runZoned(() { + try { + return callback(); + } on Object catch (error, stackTrace) { + // Forward synchronous errors through the async error path to match the + // behavior of `runZonedGuarded`. + Zone.current.handleUncaughtError(error, stackTrace); + + // If the expected return type of capture() is not nullable, this will + // throw a cast exception. But the only other alternative is to throw + // some other exception. Casting null to T at least lets existing uses + // where T is a nullable type continue to work. + return null as T; + } + }, zoneSpecification: spec.toSpec(), zoneValues: { + ...?zoneValues, + _specKey: spec, + StackZoneSpecification.disableKey: false + }); + } + + /// If [when] is `true` and this is called within a [Chain.capture] zone, runs + /// [callback] in a [Zone] in which chain capturing is disabled. + /// + /// If [callback] returns a value, it will be returned by [disable] as well. + static T disable(T Function() callback, {bool when = true}) { + var zoneValues = + when ? {_specKey: null, StackZoneSpecification.disableKey: true} : null; + + return runZoned(callback, zoneValues: zoneValues); + } + + /// Returns [futureOrStream] unmodified. + /// + /// Prior to Dart 1.7, this was necessary to ensure that stack traces for + /// exceptions reported with [Completer.completeError] and + /// [StreamController.addError] were tracked correctly. + @Deprecated('Chain.track is not necessary in Dart 1.7+.') + static dynamic track(Object? futureOrStream) => futureOrStream; + + /// Returns the current stack chain. + /// + /// By default, the first frame of the first trace will be the line where + /// [Chain.current] is called. If [level] is passed, the first trace will + /// start that many frames up instead. + /// + /// If this is called outside of a [capture] zone, it just returns a + /// single-trace chain. + factory Chain.current([int level = 0]) { + if (_currentSpec != null) return _currentSpec!.currentChain(level + 1); + + var chain = Chain.forTrace(StackTrace.current); + return LazyChain(() { + // JS includes a frame for the call to StackTrace.current, but the VM + // doesn't, so we skip an extra frame in a JS context. + var first = Trace(chain.traces.first.frames.skip(level + (inJS ? 2 : 1)), + original: chain.traces.first.original.toString()); + return Chain([first, ...chain.traces.skip(1)]); + }); + } + + /// Returns the stack chain associated with [trace]. + /// + /// The first stack trace in the returned chain will always be [trace] + /// (converted to a [Trace] if necessary). If there is no chain associated + /// with [trace] or if this is called outside of a [capture] zone, this just + /// returns a single-trace chain containing [trace]. + /// + /// If [trace] is already a [Chain], it will be returned as-is. + factory Chain.forTrace(StackTrace trace) { + if (trace is Chain) return trace; + if (_currentSpec != null) return _currentSpec!.chainFor(trace); + if (trace is Trace) return Chain([trace]); + return LazyChain(() => Chain.parse(trace.toString())); + } + + /// Parses a string representation of a stack chain. + /// + /// If [chain] is the output of a call to [Chain.toString], it will be parsed + /// as a full stack chain. Otherwise, it will be parsed as in [Trace.parse] + /// and returned as a single-trace chain. + factory Chain.parse(String chain) { + if (chain.isEmpty) return Chain([]); + if (chain.contains(vmChainGap)) { + return Chain(chain + .split(vmChainGap) + .where((line) => line.isNotEmpty) + .map(Trace.parseVM)); + } + if (!chain.contains(chainGap)) return Chain([Trace.parse(chain)]); + + return Chain(chain.split(chainGap).map(Trace.parseFriendly)); + } + + /// Returns a new [Chain] comprised of [traces]. + Chain(Iterable traces) : traces = List.unmodifiable(traces); + + /// Returns a terser version of this chain. + /// + /// This calls [Trace.terse] on every trace in [traces], and discards any + /// trace that contain only internal frames. + /// + /// This won't do anything with a raw JavaScript trace, since there's no way + /// to determine which frames come from which Dart libraries. However, the + /// [`source_map_stack_trace`](https://pub.dev/packages/source_map_stack_trace) + /// package can be used to convert JavaScript traces into Dart-style traces. + Chain get terse => foldFrames((_) => false, terse: true); + + /// Returns a new [Chain] based on this chain where multiple stack frames + /// matching [predicate] are folded together. + /// + /// This means that whenever there are multiple frames in a row that match + /// [predicate], only the last one is kept. In addition, traces that are + /// composed entirely of frames matching [predicate] are omitted. + /// + /// This is useful for limiting the amount of library code that appears in a + /// stack trace by only showing user code and code that's called by user code. + /// + /// If [terse] is true, this will also fold together frames from the core + /// library or from this package, and simplify core library frames as in + /// [Trace.terse]. + Chain foldFrames(bool Function(Frame) predicate, {bool terse = false}) { + var foldedTraces = + traces.map((trace) => trace.foldFrames(predicate, terse: terse)); + var nonEmptyTraces = foldedTraces.where((trace) { + // Ignore traces that contain only folded frames. + if (trace.frames.length > 1) return true; + if (trace.frames.isEmpty) return false; + + // In terse mode, the trace may have removed an outer folded frame, + // leaving a single non-folded frame. We can detect a folded frame because + // it has no line information. + if (!terse) return false; + return trace.frames.single.line != null; + }); + + // If all the traces contain only internal processing, preserve the last + // (top-most) one so that the chain isn't empty. + if (nonEmptyTraces.isEmpty && foldedTraces.isNotEmpty) { + return Chain([foldedTraces.last]); + } + + return Chain(nonEmptyTraces); + } + + /// Converts this chain to a [Trace]. + /// + /// The trace version of a chain is just the concatenation of all the traces + /// in the chain. + Trace toTrace() => Trace(traces.expand((trace) => trace.frames)); + + @override + String toString() { + // Figure out the longest path so we know how much to pad. + var longest = traces + .map((trace) => trace.frames + .map((frame) => frame.location.length) + .fold(0, math.max)) + .fold(0, math.max); + + // Don't call out to [Trace.toString] here because that doesn't ensure that + // padding is consistent across all traces. + return traces + .map((trace) => trace.frames + .map((frame) => + '${frame.location.padRight(longest)} ${frame.member}\n') + .join()) + .join(chainGap); + } +} diff --git a/pkgs/stack_trace/lib/src/frame.dart b/pkgs/stack_trace/lib/src/frame.dart new file mode 100644 index 000000000..d4043b780 --- /dev/null +++ b/pkgs/stack_trace/lib/src/frame.dart @@ -0,0 +1,458 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:path/path.dart' as path; + +import 'trace.dart'; +import 'unparsed_frame.dart'; + +// #1 Foo._bar (file:///home/nweiz/code/stuff.dart:42:21) +// #1 Foo._bar (file:///home/nweiz/code/stuff.dart:42) +// #1 Foo._bar (file:///home/nweiz/code/stuff.dart) +final _vmFrame = RegExp(r'^#\d+\s+(\S.*) \((.+?)((?::\d+){0,2})\)$'); + +// at Object.stringify (native) +// at VW.call$0 (https://example.com/stuff.dart.js:560:28) +// at VW.call$0 (eval as fn +// (https://example.com/stuff.dart.js:560:28), efn:3:28) +// at https://example.com/stuff.dart.js:560:28 +final _v8JsFrame = + RegExp(r'^\s*at (?:(\S.*?)(?: \[as [^\]]+\])? \((.*)\)|(.*))$'); + +// https://example.com/stuff.dart.js:560:28 +// https://example.com/stuff.dart.js:560 +// +// Group 1: URI, required +// Group 2: line number, required +// Group 3: column number, optional +final _v8JsUrlLocation = RegExp(r'^(.*?):(\d+)(?::(\d+))?$|native$'); + +// With names: +// +// at Error.f (wasm://wasm/0006d966:wasm-function[119]:0xbb13) +// at g (wasm://wasm/0006d966:wasm-function[796]:0x143b4) +// +// Without names: +// +// at wasm://wasm/0005168a:wasm-function[119]:0xbb13 +// at wasm://wasm/0005168a:wasm-function[796]:0x143b4 +// +// Matches named groups: +// +// - "member": optional, `Error.f` in the first example, NA in the second. +// - "uri": `wasm://wasm/0006d966`. +// - "index": `119`. +// - "offset": (hex number) `bb13`. +// +// To avoid having multiple groups for the same part of the frame, this regex +// matches unmatched parentheses after the member name. +final _v8WasmFrame = RegExp(r'^\s*at (?:(?.+) )?' + r'(?:\(?(?:(?\S+):wasm-function\[(?\d+)\]' + r'\:0x(?[0-9a-fA-F]+))\)?)$'); + +// eval as function (https://example.com/stuff.dart.js:560:28), efn:3:28 +// eval as function (https://example.com/stuff.dart.js:560:28) +// eval as function (eval as otherFunction +// (https://example.com/stuff.dart.js:560:28)) +final _v8EvalLocation = + RegExp(r'^eval at (?:\S.*?) \((.*)\)(?:, .*?:\d+:\d+)?$'); + +// anonymous/<@https://example.com/stuff.js line 693 > Function:3:40 +// anonymous/<@https://example.com/stuff.js line 693 > eval:3:40 +final _firefoxEvalLocation = + RegExp(r'(\S+)@(\S+) line (\d+) >.* (Function|eval):\d+:\d+'); + +// .VW.call$0@https://example.com/stuff.dart.js:560 +// .VW.call$0("arg")@https://example.com/stuff.dart.js:560 +// .VW.call$0/name<@https://example.com/stuff.dart.js:560 +// .VW.call$0@https://example.com/stuff.dart.js:560:36 +// https://example.com/stuff.dart.js:560 +final _firefoxSafariJSFrame = RegExp(r'^' + r'(?:' // Member description. Not present in some Safari frames. + r'([^@(/]*)' // The actual name of the member. + r'(?:\(.*\))?' // Arguments to the member, sometimes captured by Firefox. + r'((?:/[^/]*)*)' // Extra characters indicating a nested closure. + r'(?:\(.*\))?' // Arguments to the closure. + r'@' + r')?' + r'(.*?)' // The frame's URL. + r':' + r'(\d*)' // The line number. Empty in Safari if it's unknown. + r'(?::(\d*))?' // The column number. Not present in older browsers and + // empty in Safari if it's unknown. + r'$'); + +// With names: +// +// g@http://localhost:8080/test.wasm:wasm-function[796]:0x143b4 +// f@http://localhost:8080/test.wasm:wasm-function[795]:0x143a8 +// main@http://localhost:8080/test.wasm:wasm-function[792]:0x14390 +// +// Without names: +// +// @http://localhost:8080/test.wasm:wasm-function[796]:0x143b4 +// @http://localhost:8080/test.wasm:wasm-function[795]:0x143a8 +// @http://localhost:8080/test.wasm:wasm-function[792]:0x14390 +// +// JSShell in the command line uses a different format, which this regex also +// parses. +// +// With names: +// +// main@/home/user/test.mjs line 29 > WebAssembly.compile:wasm-function[792]:0x14378 +// +// Without names: +// +// @/home/user/test.mjs line 29 > WebAssembly.compile:wasm-function[792]:0x14378 +// +// Matches named groups: +// +// - "member": Function name, may be empty: `g`. +// - "uri": `http://localhost:8080/test.wasm`. +// - "index": `796`. +// - "offset": (in hex) `143b4`. +final _firefoxWasmFrame = + RegExp(r'^(?.*?)@(?:(?\S+).*?:wasm-function' + r'\[(?\d+)\]:0x(?[0-9a-fA-F]+))$'); + +// With names: +// +// (Note: Lines below are literal text, e.g. is not a placeholder, it's a +// part of the stack frame.) +// +// .wasm-function[g]@[wasm code] +// .wasm-function[f]@[wasm code] +// .wasm-function[main]@[wasm code] +// +// Without names: +// +// .wasm-function[796]@[wasm code] +// .wasm-function[795]@[wasm code] +// .wasm-function[792]@[wasm code] +// +// Matches named group "member": `g` or `796`. +final _safariWasmFrame = + RegExp(r'^.*?wasm-function\[(?.*)\]@\[wasm code\]$'); + +// foo/bar.dart 10:11 Foo._bar +// foo/bar.dart 10:11 (anonymous function).dart.fn +// https://dart.dev/foo/bar.dart Foo._bar +// data:... 10:11 Foo._bar +final _friendlyFrame = RegExp(r'^(\S+)(?: (\d+)(?::(\d+))?)?\s+([^\d].*)$'); + +/// A regular expression that matches asynchronous member names generated by the +/// VM. +final _asyncBody = RegExp(r'<(|[^>]+)_async_body>'); + +final _initialDot = RegExp(r'^\.'); + +/// A single stack frame. Each frame points to a precise location in Dart code. +class Frame { + /// The URI of the file in which the code is located. + /// + /// This URI will usually have the scheme `dart`, `file`, `http`, or `https`. + final Uri uri; + + /// The line number on which the code location is located. + /// + /// This can be null, indicating that the line number is unknown or + /// unimportant. + final int? line; + + /// The column number of the code location. + /// + /// This can be null, indicating that the column number is unknown or + /// unimportant. + final int? column; + + /// The name of the member in which the code location occurs. + /// + /// Anonymous closures are represented as `` in this member string. + final String? member; + + /// Whether this stack frame comes from the Dart core libraries. + bool get isCore => uri.scheme == 'dart'; + + /// Returns a human-friendly description of the library that this stack frame + /// comes from. + /// + /// This will usually be the string form of [uri], but a relative URI will be + /// used if possible. Data URIs will be truncated. + String get library { + if (uri.scheme == 'data') return 'data:...'; + return path.prettyUri(uri); + } + + /// Returns the name of the package this stack frame comes from, or `null` if + /// this stack frame doesn't come from a `package:` URL. + String? get package { + if (uri.scheme != 'package') return null; + return uri.path.split('/').first; + } + + /// A human-friendly description of the code location. + String get location { + if (line == null) return library; + if (column == null) return '$library $line'; + return '$library $line:$column'; + } + + /// Returns a single frame of the current stack. + /// + /// By default, this will return the frame above the current method. If + /// [level] is `0`, it will return the current method's frame; if [level] is + /// higher than `1`, it will return higher frames. + factory Frame.caller([int level = 1]) { + if (level < 0) { + throw ArgumentError('Argument [level] must be greater than or equal ' + 'to 0.'); + } + + return Trace.current(level + 1).frames.first; + } + + /// Parses a string representation of a Dart VM stack frame. + factory Frame.parseVM(String frame) => _catchFormatException(frame, () { + // The VM sometimes folds multiple stack frames together and replaces + // them with "...". + if (frame == '...') { + return Frame(Uri(), null, null, '...'); + } + + var match = _vmFrame.firstMatch(frame); + if (match == null) return UnparsedFrame(frame); + + // Get the pieces out of the regexp match. Function, URI and line should + // always be found. The column is optional. + var member = match[1]! + .replaceAll(_asyncBody, '') + .replaceAll('', ''); + var uri = match[2]!.startsWith(' 1 ? int.parse(lineAndColumn[1]) : null; + var column = + lineAndColumn.length > 2 ? int.parse(lineAndColumn[2]) : null; + return Frame(uri, line, column, member); + }); + + /// Parses a string representation of a Chrome/V8 stack frame. + factory Frame.parseV8(String frame) => _catchFormatException(frame, () { + // Try to match a Wasm frame first: the Wasm frame regex won't match a + // JS frame but the JS frame regex may match a Wasm frame. + var match = _v8WasmFrame.firstMatch(frame); + if (match != null) { + final member = match.namedGroup('member'); + final uri = _uriOrPathToUri(match.namedGroup('uri')!); + final functionIndex = match.namedGroup('index')!; + final functionOffset = + int.parse(match.namedGroup('offset')!, radix: 16); + return Frame(uri, 1, functionOffset + 1, member ?? functionIndex); + } + + match = _v8JsFrame.firstMatch(frame); + if (match != null) { + // v8 location strings can be arbitrarily-nested, since it adds a + // layer of nesting for each eval performed on that line. + Frame parseJsLocation(String location, String member) { + var evalMatch = _v8EvalLocation.firstMatch(location); + while (evalMatch != null) { + location = evalMatch[1]!; + evalMatch = _v8EvalLocation.firstMatch(location); + } + + if (location == 'native') { + return Frame(Uri.parse('native'), null, null, member); + } + + var urlMatch = _v8JsUrlLocation.firstMatch(location); + if (urlMatch == null) return UnparsedFrame(frame); + + final uri = _uriOrPathToUri(urlMatch[1]!); + final line = int.parse(urlMatch[2]!); + final columnMatch = urlMatch[3]; + final column = columnMatch != null ? int.parse(columnMatch) : null; + return Frame(uri, line, column, member); + } + + // V8 stack frames can be in two forms. + if (match[2] != null) { + // The first form looks like " at FUNCTION (LOCATION)". V8 proper + // lists anonymous functions within eval as "", while + // IE10 lists them as "Anonymous function". + return parseJsLocation( + match[2]!, + match[1]! + .replaceAll('', '') + .replaceAll('Anonymous function', '') + .replaceAll('(anonymous function)', '')); + } else { + // The second form looks like " at LOCATION", and is used for + // anonymous functions. + return parseJsLocation(match[3]!, ''); + } + } + + return UnparsedFrame(frame); + }); + + /// Parses a string representation of a JavaScriptCore stack trace. + factory Frame.parseJSCore(String frame) => Frame.parseV8(frame); + + /// Parses a string representation of an IE stack frame. + /// + /// IE10+ frames look just like V8 frames. Prior to IE10, stack traces can't + /// be retrieved. + factory Frame.parseIE(String frame) => Frame.parseV8(frame); + + /// Parses a Firefox 'eval' or 'function' stack frame. + /// + /// For example: + /// + /// ``` + /// anonymous/<@https://example.com/stuff.js line 693 > Function:3:40 + /// anonymous/<@https://example.com/stuff.js line 693 > eval:3:40 + /// ``` + factory Frame._parseFirefoxEval(String frame) => + _catchFormatException(frame, () { + final match = _firefoxEvalLocation.firstMatch(frame); + if (match == null) return UnparsedFrame(frame); + var member = match[1]!.replaceAll('/<', ''); + final uri = _uriOrPathToUri(match[2]!); + final line = int.parse(match[3]!); + if (member.isEmpty || member == 'anonymous') { + member = ''; + } + return Frame(uri, line, null, member); + }); + + /// Parses a string representation of a Firefox or Safari stack frame. + factory Frame.parseFirefox(String frame) => _catchFormatException(frame, () { + var match = _firefoxSafariJSFrame.firstMatch(frame); + if (match != null) { + if (match[3]!.contains(' line ')) { + return Frame._parseFirefoxEval(frame); + } + + // Normally this is a URI, but in a jsshell trace it can be a path. + var uri = _uriOrPathToUri(match[3]!); + + var member = match[1]; + if (member != null) { + member += + List.filled('/'.allMatches(match[2]!).length, '.').join(); + if (member == '') member = ''; + + // Some Firefox members have initial dots. We remove them for + // consistency with other platforms. + member = member.replaceFirst(_initialDot, ''); + } else { + member = ''; + } + + var line = match[4] == '' ? null : int.parse(match[4]!); + var column = + match[5] == null || match[5] == '' ? null : int.parse(match[5]!); + return Frame(uri, line, column, member); + } + + match = _firefoxWasmFrame.firstMatch(frame); + if (match != null) { + final member = match.namedGroup('member')!; + final uri = _uriOrPathToUri(match.namedGroup('uri')!); + final functionIndex = match.namedGroup('index')!; + final functionOffset = + int.parse(match.namedGroup('offset')!, radix: 16); + return Frame(uri, 1, functionOffset + 1, + member.isNotEmpty ? member : functionIndex); + } + + match = _safariWasmFrame.firstMatch(frame); + if (match != null) { + final member = match.namedGroup('member')!; + return Frame(Uri(path: 'wasm code'), null, null, member); + } + + return UnparsedFrame(frame); + }); + + /// Parses a string representation of a Safari 6.0 stack frame. + @Deprecated('Use Frame.parseSafari instead.') + factory Frame.parseSafari6_0(String frame) => Frame.parseFirefox(frame); + + /// Parses a string representation of a Safari 6.1+ stack frame. + @Deprecated('Use Frame.parseSafari instead.') + factory Frame.parseSafari6_1(String frame) => Frame.parseFirefox(frame); + + /// Parses a string representation of a Safari stack frame. + factory Frame.parseSafari(String frame) => Frame.parseFirefox(frame); + + /// Parses this package's string representation of a stack frame. + factory Frame.parseFriendly(String frame) => _catchFormatException(frame, () { + var match = _friendlyFrame.firstMatch(frame); + if (match == null) { + throw FormatException( + "Couldn't parse package:stack_trace stack trace line '$frame'."); + } + // Fake truncated data urls generated by the friendly stack trace format + // cause Uri.parse to throw an exception so we have to special case + // them. + var uri = match[1] == 'data:...' + ? Uri.dataFromString('') + : Uri.parse(match[1]!); + // If there's no scheme, this is a relative URI. We should interpret it + // as relative to the current working directory. + if (uri.scheme == '') { + uri = path.toUri(path.absolute(path.fromUri(uri))); + } + + var line = match[2] == null ? null : int.parse(match[2]!); + var column = match[3] == null ? null : int.parse(match[3]!); + return Frame(uri, line, column, match[4]); + }); + + /// A regular expression matching an absolute URI. + static final _uriRegExp = RegExp(r'^[a-zA-Z][-+.a-zA-Z\d]*://'); + + /// A regular expression matching a Windows path. + static final _windowsRegExp = RegExp(r'^([a-zA-Z]:[\\/]|\\\\)'); + + /// Converts [uriOrPath], which can be a URI, a Windows path, or a Posix path, + /// to a URI (absolute if possible). + static Uri _uriOrPathToUri(String uriOrPath) { + if (uriOrPath.contains(_uriRegExp)) { + return Uri.parse(uriOrPath); + } else if (uriOrPath.contains(_windowsRegExp)) { + return Uri.file(uriOrPath, windows: true); + } else if (uriOrPath.startsWith('/')) { + return Uri.file(uriOrPath, windows: false); + } + + // As far as I've seen, Firefox and V8 both always report absolute paths in + // their stack frames. However, if we do get a relative path, we should + // handle it gracefully. + if (uriOrPath.contains('\\')) return path.windows.toUri(uriOrPath); + return Uri.parse(uriOrPath); + } + + /// Runs [body] and returns its result. + /// + /// If [body] throws a [FormatException], returns an [UnparsedFrame] with + /// [text] instead. + static Frame _catchFormatException(String text, Frame Function() body) { + try { + return body(); + } on FormatException catch (_) { + return UnparsedFrame(text); + } + } + + Frame(this.uri, this.line, this.column, this.member); + + @override + String toString() => '$location in $member'; +} diff --git a/pkgs/stack_trace/lib/src/lazy_chain.dart b/pkgs/stack_trace/lib/src/lazy_chain.dart new file mode 100644 index 000000000..063ed59db --- /dev/null +++ b/pkgs/stack_trace/lib/src/lazy_chain.dart @@ -0,0 +1,33 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'chain.dart'; +import 'frame.dart'; +import 'lazy_trace.dart'; +import 'trace.dart'; + +/// A thunk for lazily constructing a [Chain]. +typedef ChainThunk = Chain Function(); + +/// A wrapper around a [ChainThunk]. This works around issue 9579 by avoiding +/// the conversion of native [StackTrace]s to strings until it's absolutely +/// necessary. +class LazyChain implements Chain { + final ChainThunk _thunk; + late final Chain _chain = _thunk(); + + LazyChain(this._thunk); + + @override + List get traces => _chain.traces; + @override + Chain get terse => _chain.terse; + @override + Chain foldFrames(bool Function(Frame) predicate, {bool terse = false}) => + LazyChain(() => _chain.foldFrames(predicate, terse: terse)); + @override + Trace toTrace() => LazyTrace(_chain.toTrace); + @override + String toString() => _chain.toString(); +} diff --git a/pkgs/stack_trace/lib/src/lazy_trace.dart b/pkgs/stack_trace/lib/src/lazy_trace.dart new file mode 100644 index 000000000..3ecaa2df0 --- /dev/null +++ b/pkgs/stack_trace/lib/src/lazy_trace.dart @@ -0,0 +1,33 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'frame.dart'; +import 'trace.dart'; + +/// A thunk for lazily constructing a [Trace]. +typedef TraceThunk = Trace Function(); + +/// A wrapper around a [TraceThunk]. This works around issue 9579 by avoiding +/// the conversion of native [StackTrace]s to strings until it's absolutely +/// necessary. +class LazyTrace implements Trace { + final TraceThunk _thunk; + late final Trace _trace = _thunk(); + + LazyTrace(this._thunk); + + @override + List get frames => _trace.frames; + @override + StackTrace get original => _trace.original; + @override + StackTrace get vmTrace => _trace.vmTrace; + @override + Trace get terse => LazyTrace(() => _trace.terse); + @override + Trace foldFrames(bool Function(Frame) predicate, {bool terse = false}) => + LazyTrace(() => _trace.foldFrames(predicate, terse: terse)); + @override + String toString() => _trace.toString(); +} diff --git a/pkgs/stack_trace/lib/src/stack_zone_specification.dart b/pkgs/stack_trace/lib/src/stack_zone_specification.dart new file mode 100644 index 000000000..901a5ee8b --- /dev/null +++ b/pkgs/stack_trace/lib/src/stack_zone_specification.dart @@ -0,0 +1,262 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'chain.dart'; +import 'lazy_chain.dart'; +import 'lazy_trace.dart'; +import 'trace.dart'; +import 'utils.dart'; + +/// A class encapsulating the zone specification for a [Chain.capture] zone. +/// +/// Until they're materialized and exposed to the user, stack chains are tracked +/// as linked lists of [Trace]s using the [_Node] class. These nodes are stored +/// in three distinct ways: +/// +/// * When a callback is registered, a node is created and stored as a captured +/// local variable until the callback is run. +/// +/// * When a callback is run, its captured node is set as the [_currentNode] so +/// it can be available to [Chain.current] and to be linked into additional +/// chains when more callbacks are scheduled. +/// +/// * When a callback throws an error or a Future or Stream emits an error, the +/// current node is associated with that error's stack trace using the +/// [_chains] expando. +/// +/// Since [ZoneSpecification] can't be extended or even implemented, in order to +/// get a real [ZoneSpecification] instance it's necessary to call [toSpec]. +class StackZoneSpecification { + /// An opaque object used as a zone value to disable chain tracking in a given + /// zone. + /// + /// If `Zone.current[disableKey]` is `true`, no stack chains will be tracked. + static final disableKey = Object(); + + /// Whether chain-tracking is disabled in the current zone. + bool get _disabled => Zone.current[disableKey] == true; + + /// The expando that associates stack chains with [StackTrace]s. + /// + /// The chains are associated with stack traces rather than errors themselves + /// because it's a common practice to throw strings as errors, which can't be + /// used with expandos. + /// + /// The chain associated with a given stack trace doesn't contain a node for + /// that stack trace. + final _chains = Expando<_Node>('stack chains'); + + /// The error handler for the zone. + /// + /// If this is null, that indicates that any unhandled errors should be passed + /// to the parent zone. + final void Function(Object error, Chain)? _onError; + + /// The most recent node of the current stack chain. + _Node? _currentNode; + + /// Whether this is an error zone. + final bool _errorZone; + + StackZoneSpecification(this._onError, {bool errorZone = true}) + : _errorZone = errorZone; + + /// Converts this specification to a real [ZoneSpecification]. + ZoneSpecification toSpec() => ZoneSpecification( + handleUncaughtError: _errorZone ? _handleUncaughtError : null, + registerCallback: _registerCallback, + registerUnaryCallback: _registerUnaryCallback, + registerBinaryCallback: _registerBinaryCallback, + errorCallback: _errorCallback); + + /// Returns the current stack chain. + /// + /// By default, the first frame of the first trace will be the line where + /// [currentChain] is called. If [level] is passed, the first trace will start + /// that many frames up instead. + Chain currentChain([int level = 0]) => _createNode(level + 1).toChain(); + + /// Returns the stack chain associated with [trace], if one exists. + /// + /// The first stack trace in the returned chain will always be [trace] + /// (converted to a [Trace] if necessary). If there is no chain associated + /// with [trace], this just returns a single-trace chain containing [trace]. + Chain chainFor(StackTrace? trace) { + if (trace is Chain) return trace; + trace ??= StackTrace.current; + + var previous = _chains[trace] ?? _currentNode; + if (previous == null) { + // If there's no [_currentNode], we're running synchronously beneath + // [Chain.capture] and we should fall back to the VM's stack chaining. We + // can't use [Chain.from] here because it'll just call [chainFor] again. + if (trace is Trace) return Chain([trace]); + return LazyChain(() => Chain.parse(trace!.toString())); + } else { + if (trace is! Trace) { + var original = trace; + trace = LazyTrace(() => Trace.parse(_trimVMChain(original))); + } + + return _Node(trace, previous).toChain(); + } + } + + /// Tracks the current stack chain so it can be set to [_currentNode] when + /// [f] is run. + ZoneCallback _registerCallback( + Zone self, ZoneDelegate parent, Zone zone, R Function() f) { + if (_disabled) return parent.registerCallback(zone, f); + var node = _createNode(1); + return parent.registerCallback(zone, () => _run(f, node)); + } + + /// Tracks the current stack chain so it can be set to [_currentNode] when + /// [f] is run. + ZoneUnaryCallback _registerUnaryCallback( + Zone self, + ZoneDelegate parent, + Zone zone, + @pragma('vm:awaiter-link') R Function(T) f) { + if (_disabled) return parent.registerUnaryCallback(zone, f); + var node = _createNode(1); + return parent.registerUnaryCallback( + zone, (arg) => _run(() => f(arg), node)); + } + + /// Tracks the current stack chain so it can be set to [_currentNode] when + /// [f] is run. + ZoneBinaryCallback _registerBinaryCallback( + Zone self, ZoneDelegate parent, Zone zone, R Function(T1, T2) f) { + if (_disabled) return parent.registerBinaryCallback(zone, f); + + var node = _createNode(1); + return parent.registerBinaryCallback( + zone, (arg1, arg2) => _run(() => f(arg1, arg2), node)); + } + + /// Looks up the chain associated with [stackTrace] and passes it either to + /// [_onError] or [parent]'s error handler. + void _handleUncaughtError(Zone self, ZoneDelegate parent, Zone zone, + Object error, StackTrace stackTrace) { + if (_disabled) { + parent.handleUncaughtError(zone, error, stackTrace); + return; + } + + var stackChain = chainFor(stackTrace); + if (_onError == null) { + parent.handleUncaughtError(zone, error, stackChain); + return; + } + + // TODO(nweiz): Currently this copies a lot of logic from [runZoned]. Just + // allow [runBinary] to throw instead once issue 18134 is fixed. + try { + // TODO(rnystrom): Is the null-assertion correct here? It is nullable in + // Zone. Should we check for that here? + self.parent!.runBinary(_onError, error, stackChain); + } on Object catch (newError, newStackTrace) { + if (identical(newError, error)) { + parent.handleUncaughtError(zone, error, stackChain); + } else { + parent.handleUncaughtError(zone, newError, newStackTrace); + } + } + } + + /// Attaches the current stack chain to [stackTrace], replacing it if + /// necessary. + AsyncError? _errorCallback(Zone self, ZoneDelegate parent, Zone zone, + Object error, StackTrace? stackTrace) { + if (_disabled) return parent.errorCallback(zone, error, stackTrace); + + // Go up two levels to get through [_CustomZone.errorCallback]. + if (stackTrace == null) { + stackTrace = _createNode(2).toChain(); + } else { + if (_chains[stackTrace] == null) _chains[stackTrace] = _createNode(2); + } + + var asyncError = parent.errorCallback(zone, error, stackTrace); + return asyncError ?? AsyncError(error, stackTrace); + } + + /// Creates a [_Node] with the current stack trace and linked to + /// [_currentNode]. + /// + /// By default, the first frame of the first trace will be the line where + /// [_createNode] is called. If [level] is passed, the first trace will start + /// that many frames up instead. + _Node _createNode([int level = 0]) => + _Node(_currentTrace(level + 1), _currentNode); + + // TODO(nweiz): use a more robust way of detecting and tracking errors when + // issue 15105 is fixed. + /// Runs [f] with [_currentNode] set to [node]. + /// + /// If [f] throws an error, this associates [node] with that error's stack + /// trace. + T _run(T Function() f, _Node node) { + var previousNode = _currentNode; + _currentNode = node; + try { + return f(); + } catch (e, stackTrace) { + // We can see the same stack trace multiple times if it's rethrown through + // guarded callbacks. The innermost chain will have the most + // information so it should take precedence. + _chains[stackTrace] ??= node; + rethrow; + } finally { + _currentNode = previousNode; + } + } + + /// Like [Trace.current], but if the current stack trace has VM chaining + /// enabled, this only returns the innermost sub-trace. + Trace _currentTrace([int? level]) { + var stackTrace = StackTrace.current; + return LazyTrace(() { + var text = _trimVMChain(stackTrace); + var trace = Trace.parse(text); + // JS includes a frame for the call to StackTrace.current, but the VM + // doesn't, so we skip an extra frame in a JS context. + return Trace(trace.frames.skip((level ?? 0) + (inJS ? 2 : 1)), + original: text); + }); + } + + /// Removes the VM's stack chains from the native [trace], since we're + /// generating our own and we don't want duplicate frames. + String _trimVMChain(StackTrace trace) { + var text = trace.toString(); + var index = text.indexOf(vmChainGap); + return index == -1 ? text : text.substring(0, index); + } +} + +/// A linked list node representing a single entry in a stack chain. +class _Node { + /// The stack trace for this link of the chain. + final Trace trace; + + /// The previous node in the chain. + final _Node? previous; + + _Node(StackTrace trace, [this.previous]) : trace = Trace.from(trace); + + /// Converts this to a [Chain]. + Chain toChain() { + var nodes = []; + _Node? node = this; + while (node != null) { + nodes.add(node.trace); + node = node.previous; + } + return Chain(nodes); + } +} diff --git a/pkgs/stack_trace/lib/src/trace.dart b/pkgs/stack_trace/lib/src/trace.dart new file mode 100644 index 000000000..b8c62f5cf --- /dev/null +++ b/pkgs/stack_trace/lib/src/trace.dart @@ -0,0 +1,341 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:math' as math; + +import 'chain.dart'; +import 'frame.dart'; +import 'lazy_trace.dart'; +import 'unparsed_frame.dart'; +import 'utils.dart'; +import 'vm_trace.dart'; + +final _terseRegExp = RegExp(r'(-patch)?([/\\].*)?$'); + +/// A RegExp to match V8's stack traces. +/// +/// V8's traces start with a line that's either just "Error" or else is a +/// description of the exception that occurred. That description can be multiple +/// lines, so we just look for any line other than the first that begins with +/// three or four spaces and "at". +final _v8Trace = RegExp(r'\n ?at '); + +/// A RegExp to match indidual lines of V8's stack traces. +/// +/// This is intended to filter out the leading exception details of the trace +/// though it is possible for the message to match this as well. +final _v8TraceLine = RegExp(r' ?at '); + +/// A RegExp to match Firefox's eval and Function stack traces. +/// +/// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/stack +/// +/// These stack traces look like: +/// +/// ```` +/// anonymous/<@https://example.com/stuff.js line 693 > Function:3:40 +/// anonymous/<@https://example.com/stuff.js line 693 > eval:3:40 +/// ```` +final _firefoxEvalTrace = RegExp(r'@\S+ line \d+ >.* (Function|eval):\d+:\d+'); + +/// A RegExp to match Firefox and Safari's stack traces. +/// +/// Firefox and Safari have very similar stack trace formats, so we use the same +/// logic for parsing them. +/// +/// Firefox's trace frames start with the name of the function in which the +/// error occurred, possibly including its parameters inside `()`. For example, +/// `.VW.call$0("arg")@https://example.com/stuff.dart.js:560`. +/// +/// Safari traces occasionally don't include the initial method name followed by +/// "@", and they always have both the line and column number (or just a +/// trailing colon if no column number is available). They can also contain +/// empty lines or lines consisting only of `[native code]`. +final _firefoxSafariTrace = RegExp( + r'^' + r'(' // Member description. Not present in some Safari frames. + r'([.0-9A-Za-z_$/<]|\(.*\))*' // Member name and arguments. + r'@' + r')?' + r'[^\s]*' // Frame URL. + r':\d*' // Line or column number. Some older frames only have a line number. + r'$', + multiLine: true); + +/// A RegExp to match this package's stack traces. +final _friendlyTrace = + RegExp(r'^[^\s<][^\s]*( \d+(:\d+)?)?[ \t]+[^\s]+$', multiLine: true); + +/// A stack trace, comprised of a list of stack frames. +class Trace implements StackTrace { + /// The stack frames that comprise this stack trace. + final List frames; + + /// The original stack trace from which this trace was parsed. + final StackTrace original; + + /// Returns a human-readable representation of [stackTrace]. If [terse] is + /// set, this folds together multiple stack frames from the Dart core + /// libraries, so that only the core library method directly called from user + /// code is visible (see [Trace.terse]). + static String format(StackTrace stackTrace, {bool terse = true}) { + var trace = Trace.from(stackTrace); + if (terse) trace = trace.terse; + return trace.toString(); + } + + /// Returns the current stack trace. + /// + /// By default, the first frame of this trace will be the line where + /// [Trace.current] is called. If [level] is passed, the trace will start that + /// many frames up instead. + factory Trace.current([int level = 0]) { + if (level < 0) { + throw ArgumentError('Argument [level] must be greater than or equal ' + 'to 0.'); + } + + var trace = Trace.from(StackTrace.current); + return LazyTrace( + () => + // JS includes a frame for the call to StackTrace.current, but the VM + // doesn't, so we skip an extra frame in a JS context. + Trace(trace.frames.skip(level + (inJS ? 2 : 1)), + original: trace.original.toString()), + ); + } + + /// Returns a new stack trace containing the same data as [trace]. + /// + /// If [trace] is a native [StackTrace], its data will be parsed out; if it's + /// a [Trace], it will be returned as-is. + factory Trace.from(StackTrace trace) { + if (trace is Trace) return trace; + if (trace is Chain) return trace.toTrace(); + return LazyTrace(() => Trace.parse(trace.toString())); + } + + /// Parses a string representation of a stack trace. + /// + /// [trace] should be formatted in the same way as a Dart VM or browser stack + /// trace. If it's formatted as a stack chain, this will return the equivalent + /// of [Chain.toTrace]. + factory Trace.parse(String trace) { + try { + if (trace.isEmpty) return Trace([]); + if (trace.contains(_v8Trace)) return Trace.parseV8(trace); + if (trace.contains('\tat ')) return Trace.parseJSCore(trace); + if (trace.contains(_firefoxSafariTrace) || + trace.contains(_firefoxEvalTrace)) { + return Trace.parseFirefox(trace); + } + if (trace.contains(chainGap)) return Chain.parse(trace).toTrace(); + if (trace.contains(_friendlyTrace)) { + return Trace.parseFriendly(trace); + } + + // Default to parsing the stack trace as a VM trace. This is also hit on + // IE and Safari, where the stack trace is just an empty string (issue + // 11257). + return Trace.parseVM(trace); + } on FormatException catch (error) { + throw FormatException('${error.message}\nStack trace:\n$trace'); + } + } + + /// Parses a string representation of a Dart VM stack trace. + Trace.parseVM(String trace) : this(_parseVM(trace), original: trace); + + static List _parseVM(String trace) { + // Ignore [vmChainGap]. This matches the behavior of + // `Chain.parse().toTrace()`. + var lines = trace + .trim() + .replaceAll(vmChainGap, '') + .split('\n') + .where((line) => line.isNotEmpty); + + if (lines.isEmpty) { + return []; + } + + var frames = lines.take(lines.length - 1).map(Frame.parseVM).toList(); + + // TODO(nweiz): Remove this when issue 23614 is fixed. + if (!lines.last.endsWith('.da')) { + frames.add(Frame.parseVM(lines.last)); + } + + return frames; + } + + /// Parses a string representation of a Chrome/V8 stack trace. + Trace.parseV8(String trace) + : this( + trace + .split('\n') + .skip(1) + // It's possible that an Exception's description contains a line + // that looks like a V8 trace line, which will screw this up. + // Unfortunately, that's impossible to detect. + .skipWhile((line) => !line.startsWith(_v8TraceLine)) + .map(Frame.parseV8), + original: trace); + + /// Parses a string representation of a JavaScriptCore stack trace. + Trace.parseJSCore(String trace) + : this( + trace + .split('\n') + .where((line) => line != '\tat ') + .map(Frame.parseV8), + original: trace); + + /// Parses a string representation of an Internet Explorer stack trace. + /// + /// IE10+ traces look just like V8 traces. Prior to IE10, stack traces can't + /// be retrieved. + Trace.parseIE(String trace) : this.parseV8(trace); + + /// Parses a string representation of a Firefox stack trace. + Trace.parseFirefox(String trace) + : this( + trace + .trim() + .split('\n') + .where((line) => line.isNotEmpty && line != '[native code]') + .map(Frame.parseFirefox), + original: trace); + + /// Parses a string representation of a Safari stack trace. + Trace.parseSafari(String trace) : this.parseFirefox(trace); + + /// Parses a string representation of a Safari 6.1+ stack trace. + @Deprecated('Use Trace.parseSafari instead.') + Trace.parseSafari6_1(String trace) : this.parseSafari(trace); + + /// Parses a string representation of a Safari 6.0 stack trace. + @Deprecated('Use Trace.parseSafari instead.') + Trace.parseSafari6_0(String trace) + : this( + trace + .trim() + .split('\n') + .where((line) => line != '[native code]') + .map(Frame.parseFirefox), + original: trace); + + /// Parses this package's string representation of a stack trace. + /// + /// This also parses string representations of [Chain]s. They parse to the + /// same trace that [Chain.toTrace] would return. + Trace.parseFriendly(String trace) + : this( + trace.isEmpty + ? [] + : trace + .trim() + .split('\n') + // Filter out asynchronous gaps from [Chain]s. + .where((line) => !line.startsWith('=====')) + .map(Frame.parseFriendly), + original: trace); + + /// Returns a new [Trace] comprised of [frames]. + Trace(Iterable frames, {String? original}) + : frames = List.unmodifiable(frames), + original = StackTrace.fromString(original ?? ''); + + /// Returns a VM-style [StackTrace] object. + /// + /// The return value's [toString] method will always return a string + /// representation in the Dart VM's stack trace format, regardless of what + /// platform is being used. + StackTrace get vmTrace => VMTrace(frames); + + /// Returns a terser version of this trace. + /// + /// This is accomplished by folding together multiple stack frames from the + /// core library or from this package, as in [foldFrames]. Remaining core + /// library frames have their libraries, "-patch" suffixes, and line numbers + /// removed. If the outermost frame of the stack trace is a core library + /// frame, it's removed entirely. + /// + /// This won't do anything with a raw JavaScript trace, since there's no way + /// to determine which frames come from which Dart libraries. However, the + /// [`source_map_stack_trace`][https://pub.dev/packages/source_map_stack_trace] + /// package can be used to convert JavaScript traces into Dart-style traces. + /// + /// For custom folding, see [foldFrames]. + Trace get terse => foldFrames((_) => false, terse: true); + + /// Returns a new [Trace] based on `this` where multiple stack frames matching + /// [predicate] are folded together. + /// + /// This means that whenever there are multiple frames in a row that match + /// [predicate], only the last one is kept. This is useful for limiting the + /// amount of library code that appears in a stack trace by only showing user + /// code and code that's called by user code. + /// + /// If [terse] is true, this will also fold together frames from the core + /// library or from this package, simplify core library frames, and + /// potentially remove the outermost frame as in [Trace.terse]. + Trace foldFrames(bool Function(Frame) predicate, {bool terse = false}) { + if (terse) { + var oldPredicate = predicate; + predicate = (frame) { + if (oldPredicate(frame)) return true; + + if (frame.isCore) return true; + if (frame.package == 'stack_trace') return true; + + // Ignore async stack frames without any line or column information. + // These come from the VM's async/await implementation and represent + // internal frames. They only ever show up in stack chains and are + // always surrounded by other traces that are actually useful, so we can + // just get rid of them. + // TODO(nweiz): Get rid of this logic some time after issue 22009 is + // fixed. + if (!frame.member!.contains('')) return false; + return frame.line == null; + }; + } + + var newFrames = []; + for (var frame in frames.reversed) { + if (frame is UnparsedFrame || !predicate(frame)) { + newFrames.add(frame); + } else if (newFrames.isEmpty || !predicate(newFrames.last)) { + newFrames.add(Frame(frame.uri, frame.line, frame.column, frame.member)); + } + } + + if (terse) { + newFrames = newFrames.map((frame) { + if (frame is UnparsedFrame || !predicate(frame)) return frame; + var library = frame.library.replaceAll(_terseRegExp, ''); + return Frame(Uri.parse(library), null, null, frame.member); + }).toList(); + + if (newFrames.length > 1 && predicate(newFrames.first)) { + newFrames.removeAt(0); + } + } + + return Trace(newFrames.reversed, original: original.toString()); + } + + @override + String toString() { + // Figure out the longest path so we know how much to pad. + var longest = + frames.map((frame) => frame.location.length).fold(0, math.max); + + // Print out the stack trace nicely formatted. + return frames.map((frame) { + if (frame is UnparsedFrame) return '$frame\n'; + return '${frame.location.padRight(longest)} ${frame.member}\n'; + }).join(); + } +} diff --git a/pkgs/stack_trace/lib/src/unparsed_frame.dart b/pkgs/stack_trace/lib/src/unparsed_frame.dart new file mode 100644 index 000000000..27e97f6e0 --- /dev/null +++ b/pkgs/stack_trace/lib/src/unparsed_frame.dart @@ -0,0 +1,33 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'frame.dart'; + +/// A frame that failed to parse. +/// +/// The [member] property contains the original frame's contents. +class UnparsedFrame implements Frame { + @override + final Uri uri = Uri(path: 'unparsed'); + @override + final int? line = null; + @override + final int? column = null; + @override + final bool isCore = false; + @override + final String library = 'unparsed'; + @override + final String? package = null; + @override + final String location = 'unparsed'; + + @override + final String member; + + UnparsedFrame(this.member); + + @override + String toString() => member; +} diff --git a/pkgs/stack_trace/lib/src/utils.dart b/pkgs/stack_trace/lib/src/utils.dart new file mode 100644 index 000000000..bd971fe56 --- /dev/null +++ b/pkgs/stack_trace/lib/src/utils.dart @@ -0,0 +1,15 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// The line used in the string representation of stack chains to represent +/// the gap between traces. +const chainGap = '===== asynchronous gap ===========================\n'; + +/// The line used in the string representation of VM stack chains to represent +/// the gap between traces. +final vmChainGap = RegExp(r'^\n?$', multiLine: true); + +// TODO(nweiz): When cross-platform imports work, use them to set this. +/// Whether we're running in a JS context. +const bool inJS = 0.0 is int; diff --git a/pkgs/stack_trace/lib/src/vm_trace.dart b/pkgs/stack_trace/lib/src/vm_trace.dart new file mode 100644 index 000000000..005b7afa3 --- /dev/null +++ b/pkgs/stack_trace/lib/src/vm_trace.dart @@ -0,0 +1,32 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'frame.dart'; + +/// An implementation of [StackTrace] that emulates the behavior of the VM's +/// implementation. +/// +/// In particular, when [toString] is called, this returns a string in the VM's +/// stack trace format. +class VMTrace implements StackTrace { + /// The stack frames that comprise this stack trace. + final List frames; + + VMTrace(this.frames); + + @override + String toString() { + var i = 1; + return frames.map((frame) { + var number = '#${i++}'.padRight(8); + var member = frame.member! + .replaceAllMapped(RegExp(r'[^.]+\.'), + (match) => '${match[1]}.<${match[1]}_async_body>') + .replaceAll('', ''); + var line = frame.line ?? 0; + var column = frame.column ?? 0; + return '$number$member (${frame.uri}:$line:$column)\n'; + }).join(); + } +} diff --git a/pkgs/stack_trace/lib/stack_trace.dart b/pkgs/stack_trace/lib/stack_trace.dart new file mode 100644 index 000000000..fad30ce26 --- /dev/null +++ b/pkgs/stack_trace/lib/stack_trace.dart @@ -0,0 +1,8 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'src/chain.dart'; +export 'src/frame.dart'; +export 'src/trace.dart'; +export 'src/unparsed_frame.dart'; diff --git a/pkgs/stack_trace/pubspec.yaml b/pkgs/stack_trace/pubspec.yaml new file mode 100644 index 000000000..4f387b1c4 --- /dev/null +++ b/pkgs/stack_trace/pubspec.yaml @@ -0,0 +1,14 @@ +name: stack_trace +version: 1.12.1 +description: A package for manipulating stack traces and printing them readably. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/stack_trace + +environment: + sdk: ^3.4.0 + +dependencies: + path: ^1.8.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + test: ^1.16.6 diff --git a/pkgs/stack_trace/test/chain/chain_test.dart b/pkgs/stack_trace/test/chain/chain_test.dart new file mode 100644 index 000000000..d5426dda9 --- /dev/null +++ b/pkgs/stack_trace/test/chain/chain_test.dart @@ -0,0 +1,375 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:path/path.dart' as p; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + group('Chain.parse()', () { + test('parses a real Chain', () async { + // ignore: only_throw_errors + final chain = await captureFuture(() => inMicrotask(() => throw 'error')); + + expect( + Chain.parse(chain.toString()).toString(), + equals(chain.toString()), + ); + }); + + test('parses an empty string', () { + var chain = Chain.parse(''); + expect(chain.traces, isEmpty); + }); + + test('parses a chain containing empty traces', () { + var chain = + Chain.parse('===== asynchronous gap ===========================\n' + '===== asynchronous gap ===========================\n'); + expect(chain.traces, hasLength(3)); + expect(chain.traces[0].frames, isEmpty); + expect(chain.traces[1].frames, isEmpty); + expect(chain.traces[2].frames, isEmpty); + }); + + test('parses a chain with VM gaps', () { + final chain = + Chain.parse('#1 MyClass.run (package:my_lib.dart:134:5)\n' + '\n' + '#2 main (file:///my_app.dart:9:3)\n' + '\n'); + expect(chain.traces, hasLength(2)); + expect(chain.traces[0].frames, hasLength(1)); + expect(chain.traces[0].frames[0].toString(), + equals('package:my_lib.dart 134:5 in MyClass.run')); + expect(chain.traces[1].frames, hasLength(1)); + expect( + chain.traces[1].frames[0].toString(), + anyOf( + equals('/my_app.dart 9:3 in main'), // VM + equals('file:///my_app.dart 9:3 in main'), // Browser + ), + ); + }); + }); + + group('Chain.capture()', () { + test('with onError blocks errors', () { + Chain.capture(() { + return Future.error('oh no'); + }, onError: expectAsync2((error, chain) { + expect(error, equals('oh no')); + expect(chain, isA()); + })).then(expectAsync1((_) {}, count: 0), + onError: expectAsync2((_, __) {}, count: 0)); + }); + + test('with no onError blocks errors', () { + runZonedGuarded(() { + Chain.capture(() => Future.error('oh no')).then( + expectAsync1((_) {}, count: 0), + onError: expectAsync2((_, __) {}, count: 0)); + }, expectAsync2((error, chain) { + expect(error, equals('oh no')); + expect(chain, isA()); + })); + }); + + test("with errorZone: false doesn't block errors", () { + expect(Chain.capture(() => Future.error('oh no'), errorZone: false), + throwsA('oh no')); + }); + + test("doesn't allow onError and errorZone: false", () { + expect(() => Chain.capture(() {}, onError: (_, __) {}, errorZone: false), + throwsArgumentError); + }); + + group('with when: false', () { + test("with no onError doesn't block errors", () { + expect(Chain.capture(() => Future.error('oh no'), when: false), + throwsA('oh no')); + }); + + test('with onError blocks errors', () { + Chain.capture(() { + return Future.error('oh no'); + }, onError: expectAsync2((error, chain) { + expect(error, equals('oh no')); + expect(chain, isA()); + }), when: false); + }); + + test("doesn't enable chain-tracking", () { + return Chain.disable(() { + return Chain.capture(() { + var completer = Completer(); + inMicrotask(() { + completer.complete(Chain.current()); + }); + + return completer.future.then((chain) { + expect(chain.traces, hasLength(1)); + }); + }, when: false); + }); + }); + }); + }); + + test('Chain.capture() with custom zoneValues', () { + return Chain.capture(() { + expect(Zone.current[#enabled], true); + }, zoneValues: {#enabled: true}); + }); + + group('Chain.disable()', () { + test('disables chain-tracking', () { + return Chain.disable(() { + var completer = Completer(); + inMicrotask(() => completer.complete(Chain.current())); + + return completer.future.then((chain) { + expect(chain.traces, hasLength(1)); + }); + }); + }); + + test('Chain.capture() re-enables chain-tracking', () { + return Chain.disable(() { + return Chain.capture(() { + var completer = Completer(); + inMicrotask(() => completer.complete(Chain.current())); + + return completer.future.then((chain) { + expect(chain.traces, hasLength(2)); + }); + }); + }); + }); + + test('preserves parent zones of the capture zone', () { + // The outer disable call turns off the test package's chain-tracking. + return Chain.disable(() { + return runZoned(() { + return Chain.capture(() { + expect(Chain.disable(() => Zone.current[#enabled]), isTrue); + }); + }, zoneValues: {#enabled: true}); + }); + }); + + test('preserves child zones of the capture zone', () { + // The outer disable call turns off the test package's chain-tracking. + return Chain.disable(() { + return Chain.capture(() { + return runZoned(() { + expect(Chain.disable(() => Zone.current[#enabled]), isTrue); + }, zoneValues: {#enabled: true}); + }); + }); + }); + + test("with when: false doesn't disable", () { + return Chain.capture(() { + return Chain.disable(() { + var completer = Completer(); + inMicrotask(() => completer.complete(Chain.current())); + + return completer.future.then((chain) { + expect(chain.traces, hasLength(2)); + }); + }, when: false); + }); + }); + }); + + test('toString() ensures that all traces are aligned', () { + var chain = Chain([ + Trace.parse('short 10:11 Foo.bar\n'), + Trace.parse('loooooooooooong 10:11 Zop.zoop') + ]); + + expect( + chain.toString(), + equals('short 10:11 Foo.bar\n' + '===== asynchronous gap ===========================\n' + 'loooooooooooong 10:11 Zop.zoop\n')); + }); + + var userSlashCode = p.join('user', 'code.dart'); + group('Chain.terse', () { + test('makes each trace terse', () { + var chain = Chain([ + Trace.parse('dart:core 10:11 Foo.bar\n' + 'dart:core 10:11 Bar.baz\n' + 'user/code.dart 10:11 Bang.qux\n' + 'dart:core 10:11 Zip.zap\n' + 'dart:core 10:11 Zop.zoop'), + Trace.parse('user/code.dart 10:11 Bang.qux\n' + 'dart:core 10:11 Foo.bar\n' + 'package:stack_trace/stack_trace.dart 10:11 Bar.baz\n' + 'dart:core 10:11 Zip.zap\n' + 'user/code.dart 10:11 Zop.zoop') + ]); + + expect( + chain.terse.toString(), + equals('dart:core Bar.baz\n' + '$userSlashCode 10:11 Bang.qux\n' + '===== asynchronous gap ===========================\n' + '$userSlashCode 10:11 Bang.qux\n' + 'dart:core Zip.zap\n' + '$userSlashCode 10:11 Zop.zoop\n')); + }); + + test('eliminates internal-only traces', () { + var chain = Chain([ + Trace.parse('user/code.dart 10:11 Foo.bar\n' + 'dart:core 10:11 Bar.baz'), + Trace.parse('dart:core 10:11 Foo.bar\n' + 'package:stack_trace/stack_trace.dart 10:11 Bar.baz\n' + 'dart:core 10:11 Zip.zap'), + Trace.parse('user/code.dart 10:11 Foo.bar\n' + 'dart:core 10:11 Bar.baz') + ]); + + expect( + chain.terse.toString(), + equals('$userSlashCode 10:11 Foo.bar\n' + '===== asynchronous gap ===========================\n' + '$userSlashCode 10:11 Foo.bar\n')); + }); + + test("doesn't return an empty chain", () { + var chain = Chain([ + Trace.parse('dart:core 10:11 Foo.bar\n' + 'package:stack_trace/stack_trace.dart 10:11 Bar.baz\n' + 'dart:core 10:11 Zip.zap'), + Trace.parse('dart:core 10:11 A.b\n' + 'package:stack_trace/stack_trace.dart 10:11 C.d\n' + 'dart:core 10:11 E.f') + ]); + + expect(chain.terse.toString(), equals('dart:core E.f\n')); + }); + + // Regression test for #9 + test("doesn't crash on empty traces", () { + var chain = Chain([ + Trace.parse('user/code.dart 10:11 Bang.qux'), + Trace([]), + Trace.parse('user/code.dart 10:11 Bang.qux') + ]); + + expect( + chain.terse.toString(), + equals('$userSlashCode 10:11 Bang.qux\n' + '===== asynchronous gap ===========================\n' + '$userSlashCode 10:11 Bang.qux\n')); + }); + }); + + group('Chain.foldFrames', () { + test('folds each trace', () { + var chain = Chain([ + Trace.parse('a.dart 10:11 Foo.bar\n' + 'a.dart 10:11 Bar.baz\n' + 'b.dart 10:11 Bang.qux\n' + 'a.dart 10:11 Zip.zap\n' + 'a.dart 10:11 Zop.zoop'), + Trace.parse('a.dart 10:11 Foo.bar\n' + 'a.dart 10:11 Bar.baz\n' + 'a.dart 10:11 Bang.qux\n' + 'a.dart 10:11 Zip.zap\n' + 'b.dart 10:11 Zop.zoop') + ]); + + var folded = chain.foldFrames((frame) => frame.library == 'a.dart'); + expect( + folded.toString(), + equals('a.dart 10:11 Bar.baz\n' + 'b.dart 10:11 Bang.qux\n' + 'a.dart 10:11 Zop.zoop\n' + '===== asynchronous gap ===========================\n' + 'a.dart 10:11 Zip.zap\n' + 'b.dart 10:11 Zop.zoop\n')); + }); + + test('with terse: true, folds core frames as well', () { + var chain = Chain([ + Trace.parse('a.dart 10:11 Foo.bar\n' + 'dart:async-patch/future.dart 10:11 Zip.zap\n' + 'b.dart 10:11 Bang.qux\n' + 'dart:core 10:11 Bar.baz\n' + 'a.dart 10:11 Zop.zoop'), + Trace.parse('a.dart 10:11 Foo.bar\n' + 'a.dart 10:11 Bar.baz\n' + 'a.dart 10:11 Bang.qux\n' + 'a.dart 10:11 Zip.zap\n' + 'b.dart 10:11 Zop.zoop') + ]); + + var folded = + chain.foldFrames((frame) => frame.library == 'a.dart', terse: true); + expect( + folded.toString(), + equals('dart:async Zip.zap\n' + 'b.dart 10:11 Bang.qux\n' + '===== asynchronous gap ===========================\n' + 'a.dart Zip.zap\n' + 'b.dart 10:11 Zop.zoop\n')); + }); + + test('eliminates completely-folded traces', () { + var chain = Chain([ + Trace.parse('a.dart 10:11 Foo.bar\n' + 'b.dart 10:11 Bang.qux'), + Trace.parse('a.dart 10:11 Foo.bar\n' + 'a.dart 10:11 Bang.qux'), + Trace.parse('a.dart 10:11 Zip.zap\n' + 'b.dart 10:11 Zop.zoop') + ]); + + var folded = chain.foldFrames((frame) => frame.library == 'a.dart'); + expect( + folded.toString(), + equals('a.dart 10:11 Foo.bar\n' + 'b.dart 10:11 Bang.qux\n' + '===== asynchronous gap ===========================\n' + 'a.dart 10:11 Zip.zap\n' + 'b.dart 10:11 Zop.zoop\n')); + }); + + test("doesn't return an empty trace", () { + var chain = Chain([ + Trace.parse('a.dart 10:11 Foo.bar\n' + 'a.dart 10:11 Bang.qux') + ]); + + var folded = chain.foldFrames((frame) => frame.library == 'a.dart'); + expect(folded.toString(), equals('a.dart 10:11 Bang.qux\n')); + }); + }); + + test('Chain.toTrace eliminates asynchronous gaps', () { + var trace = Chain([ + Trace.parse('user/code.dart 10:11 Foo.bar\n' + 'dart:core 10:11 Bar.baz'), + Trace.parse('user/code.dart 10:11 Foo.bar\n' + 'dart:core 10:11 Bar.baz') + ]).toTrace(); + + expect( + trace.toString(), + equals('$userSlashCode 10:11 Foo.bar\n' + 'dart:core 10:11 Bar.baz\n' + '$userSlashCode 10:11 Foo.bar\n' + 'dart:core 10:11 Bar.baz\n')); + }); +} diff --git a/pkgs/stack_trace/test/chain/dart2js_test.dart b/pkgs/stack_trace/test/chain/dart2js_test.dart new file mode 100644 index 000000000..abb842dfc --- /dev/null +++ b/pkgs/stack_trace/test/chain/dart2js_test.dart @@ -0,0 +1,337 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// ignore_for_file: only_throw_errors + +// dart2js chain tests are separated out because dart2js stack traces are +// inconsistent due to inlining and browser differences. These tests don't +// assert anything about the content of the traces, just the number of traces in +// a chain. +@TestOn('js') +library; + +import 'dart:async'; + +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + group('capture() with onError catches exceptions', () { + test('thrown synchronously', () async { + var chain = await captureFuture(() => throw 'error'); + expect(chain.traces, hasLength(1)); + }); + + test('thrown in a microtask', () async { + var chain = await captureFuture(() => inMicrotask(() => throw 'error')); + expect(chain.traces, hasLength(2)); + }); + + test('thrown in a one-shot timer', () async { + var chain = + await captureFuture(() => inOneShotTimer(() => throw 'error')); + expect(chain.traces, hasLength(2)); + }); + + test('thrown in a periodic timer', () async { + var chain = + await captureFuture(() => inPeriodicTimer(() => throw 'error')); + expect(chain.traces, hasLength(2)); + }); + + test('thrown in a nested series of asynchronous operations', () async { + var chain = await captureFuture(() { + inPeriodicTimer(() { + inOneShotTimer(() => inMicrotask(() => throw 'error')); + }); + }); + + expect(chain.traces, hasLength(4)); + }); + + test('thrown in a long future chain', () async { + var chain = await captureFuture(() => inFutureChain(() => throw 'error')); + + // Despite many asynchronous operations, there's only one level of + // nested calls, so there should be only two traces in the chain. This + // is important; programmers expect stack trace memory consumption to be + // O(depth of program), not O(length of program). + expect(chain.traces, hasLength(2)); + }); + + test('thrown in new Future()', () async { + var chain = await captureFuture(() => inNewFuture(() => throw 'error')); + expect(chain.traces, hasLength(3)); + }); + + test('thrown in new Future.sync()', () async { + var chain = await captureFuture(() { + inMicrotask(() => inSyncFuture(() => throw 'error')); + }); + + expect(chain.traces, hasLength(3)); + }); + + test('multiple times', () { + var completer = Completer(); + var first = true; + + Chain.capture(() { + inMicrotask(() => throw 'first error'); + inPeriodicTimer(() => throw 'second error'); + }, onError: (error, chain) { + try { + if (first) { + expect(error, equals('first error')); + expect(chain.traces, hasLength(2)); + first = false; + } else { + expect(error, equals('second error')); + expect(chain.traces, hasLength(2)); + completer.complete(); + } + } on Object catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + + return completer.future; + }); + + test('passed to a completer', () async { + var trace = Trace.current(); + var chain = await captureFuture(() { + inMicrotask(() => completerErrorFuture(trace)); + }); + + expect(chain.traces, hasLength(3)); + + // The first trace is the trace that was manually reported for the + // error. + expect(chain.traces.first.toString(), equals(trace.toString())); + }); + + test('passed to a completer with no stack trace', () async { + var chain = await captureFuture(() { + inMicrotask(completerErrorFuture); + }); + + expect(chain.traces, hasLength(2)); + }); + + test('passed to a stream controller', () async { + var trace = Trace.current(); + var chain = await captureFuture(() { + inMicrotask(() => controllerErrorStream(trace).listen(null)); + }); + + expect(chain.traces, hasLength(3)); + expect(chain.traces.first.toString(), equals(trace.toString())); + }); + + test('passed to a stream controller with no stack trace', () async { + var chain = await captureFuture(() { + inMicrotask(() => controllerErrorStream().listen(null)); + }); + + expect(chain.traces, hasLength(2)); + }); + + test('and relays them to the parent zone', () { + var completer = Completer(); + + runZonedGuarded(() { + Chain.capture(() { + inMicrotask(() => throw 'error'); + }, onError: (error, chain) { + expect(error, equals('error')); + expect(chain.traces, hasLength(2)); + throw error; + }); + }, (error, chain) { + try { + expect(error, equals('error')); + expect(chain, + isA().having((c) => c.traces, 'traces', hasLength(2))); + completer.complete(); + } on Object catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + + return completer.future; + }); + }); + + test('capture() without onError passes exceptions to parent zone', () { + var completer = Completer(); + + runZonedGuarded(() { + Chain.capture(() => inMicrotask(() => throw 'error')); + }, (error, chain) { + try { + expect(error, equals('error')); + expect(chain, + isA().having((c) => c.traces, 'traces', hasLength(2))); + completer.complete(); + } on Object catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + + return completer.future; + }); + + group('current() within capture()', () { + test('called in a microtask', () async { + var completer = Completer(); + Chain.capture(() { + inMicrotask(() => completer.complete(Chain.current())); + }); + + var chain = await completer.future; + expect(chain.traces, hasLength(2)); + }); + + test('called in a one-shot timer', () async { + var completer = Completer(); + Chain.capture(() { + inOneShotTimer(() => completer.complete(Chain.current())); + }); + + var chain = await completer.future; + expect(chain.traces, hasLength(2)); + }); + + test('called in a periodic timer', () async { + var completer = Completer(); + Chain.capture(() { + inPeriodicTimer(() => completer.complete(Chain.current())); + }); + + var chain = await completer.future; + expect(chain.traces, hasLength(2)); + }); + + test('called in a nested series of asynchronous operations', () async { + var completer = Completer(); + Chain.capture(() { + inPeriodicTimer(() { + inOneShotTimer(() { + inMicrotask(() => completer.complete(Chain.current())); + }); + }); + }); + + var chain = await completer.future; + expect(chain.traces, hasLength(4)); + }); + + test('called in a long future chain', () async { + var completer = Completer(); + Chain.capture(() { + inFutureChain(() => completer.complete(Chain.current())); + }); + + var chain = await completer.future; + expect(chain.traces, hasLength(2)); + }); + }); + + test( + 'current() outside of capture() returns a chain wrapping the current trace', + () => + // The test runner runs all tests with chains enabled. + Chain.disable(() async { + var completer = Completer(); + inMicrotask(() => completer.complete(Chain.current())); + + var chain = await completer.future; + // Since the chain wasn't loaded within [Chain.capture], the full stack + // chain isn't available and it just returns the current stack when + // called. + expect(chain.traces, hasLength(1)); + }), + ); + + group('forTrace() within capture()', () { + test('called for a stack trace from a microtask', () async { + var chain = await Chain.capture( + () => chainForTrace(inMicrotask, () => throw 'error')); + + // Because [chainForTrace] has to set up a future chain to capture the + // stack trace while still showing it to the zone specification, it adds + // an additional level of async nesting and so an additional trace. + expect(chain.traces, hasLength(3)); + }); + + test('called for a stack trace from a one-shot timer', () async { + var chain = await Chain.capture( + () => chainForTrace(inOneShotTimer, () => throw 'error')); + + expect(chain.traces, hasLength(3)); + }); + + test('called for a stack trace from a periodic timer', () async { + var chain = await Chain.capture( + () => chainForTrace(inPeriodicTimer, () => throw 'error')); + + expect(chain.traces, hasLength(3)); + }); + + test( + 'called for a stack trace from a nested series of asynchronous ' + 'operations', () async { + var chain = await Chain.capture(() => chainForTrace((callback) { + inPeriodicTimer(() => inOneShotTimer(() => inMicrotask(callback))); + }, () => throw 'error')); + + expect(chain.traces, hasLength(5)); + }); + + test('called for a stack trace from a long future chain', () async { + var chain = await Chain.capture( + () => chainForTrace(inFutureChain, () => throw 'error')); + + expect(chain.traces, hasLength(3)); + }); + + test( + 'called for an unregistered stack trace returns a chain wrapping that ' + 'trace', () { + late StackTrace trace; + var chain = Chain.capture(() { + try { + throw 'error'; + } catch (_, stackTrace) { + trace = stackTrace; + return Chain.forTrace(stackTrace); + } + }); + + expect(chain.traces, hasLength(1)); + expect( + chain.traces.first.toString(), equals(Trace.from(trace).toString())); + }); + }); + + test( + 'forTrace() outside of capture() returns a chain wrapping the given ' + 'trace', () { + late StackTrace trace; + var chain = Chain.capture(() { + try { + throw 'error'; + } catch (_, stackTrace) { + trace = stackTrace; + return Chain.forTrace(stackTrace); + } + }); + + expect(chain.traces, hasLength(1)); + expect(chain.traces.first.toString(), equals(Trace.from(trace).toString())); + }); +} diff --git a/pkgs/stack_trace/test/chain/utils.dart b/pkgs/stack_trace/test/chain/utils.dart new file mode 100644 index 000000000..27fb0e684 --- /dev/null +++ b/pkgs/stack_trace/test/chain/utils.dart @@ -0,0 +1,94 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/test.dart'; + +/// Runs [callback] in a microtask callback. +void inMicrotask(void Function() callback) => scheduleMicrotask(callback); + +/// Runs [callback] in a one-shot timer callback. +void inOneShotTimer(void Function() callback) => Timer.run(callback); + +/// Runs [callback] once in a periodic timer callback. +void inPeriodicTimer(void Function() callback) { + var count = 0; + Timer.periodic(const Duration(milliseconds: 1), (timer) { + count++; + if (count != 5) return; + timer.cancel(); + callback(); + }); +} + +/// Runs [callback] within a long asynchronous Future chain. +void inFutureChain(void Function() callback) { + Future(() {}) + .then((_) => Future(() {})) + .then((_) => Future(() {})) + .then((_) => Future(() {})) + .then((_) => Future(() {})) + .then((_) => callback()) + .then((_) => Future(() {})); +} + +void inNewFuture(void Function() callback) { + Future(callback); +} + +void inSyncFuture(void Function() callback) { + Future.sync(callback); +} + +/// Returns a Future that completes to an error using a completer. +/// +/// If [trace] is passed, it's used as the stack trace for the error. +Future completerErrorFuture([StackTrace? trace]) { + var completer = Completer(); + completer.completeError('error', trace); + return completer.future; +} + +/// Returns a Stream that emits an error using a controller. +/// +/// If [trace] is passed, it's used as the stack trace for the error. +Stream controllerErrorStream([StackTrace? trace]) { + var controller = StreamController(); + controller.addError('error', trace); + return controller.stream; +} + +/// Runs [callback] within [asyncFn], then converts any errors raised into a +/// [Chain] with [Chain.forTrace]. +Future chainForTrace( + void Function(void Function()) asyncFn, void Function() callback) { + var completer = Completer(); + asyncFn(() { + // We use `new Future.value().then(...)` here as opposed to [new Future] or + // [new Future.sync] because those methods don't pass the exception through + // the zone specification before propagating it, so there's no chance to + // attach a chain to its stack trace. See issue 15105. + Future.value() + .then((_) => callback()) + .catchError(completer.completeError); + }); + + return completer.future + .catchError((_, StackTrace stackTrace) => Chain.forTrace(stackTrace)); +} + +/// Runs [callback] in a [Chain.capture] zone and returns a Future that +/// completes to the stack chain for an error thrown by [callback]. +/// +/// [callback] is expected to throw the string `"error"`. +Future captureFuture(void Function() callback) { + var completer = Completer(); + Chain.capture(callback, onError: (error, chain) { + expect(error, equals('error')); + completer.complete(chain); + }); + return completer.future; +} diff --git a/pkgs/stack_trace/test/chain/vm_test.dart b/pkgs/stack_trace/test/chain/vm_test.dart new file mode 100644 index 000000000..5c6c0b7d9 --- /dev/null +++ b/pkgs/stack_trace/test/chain/vm_test.dart @@ -0,0 +1,508 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// ignore_for_file: only_throw_errors + +// VM chain tests can rely on stronger guarantees about the contents of the +// stack traces than dart2js. +@TestOn('dart-vm') +library; + +import 'dart:async'; + +import 'package:stack_trace/src/utils.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; +import 'utils.dart'; + +void main() { + group('capture() with onError catches exceptions', () { + test('thrown synchronously', () async { + late StackTrace vmTrace; + var chain = await captureFuture(() { + try { + throw 'error'; + } catch (_, stackTrace) { + vmTrace = stackTrace; + rethrow; + } + }); + + // Because there's no chain context for a synchronous error, we fall back + // on the VM's stack chain tracking. + expect( + chain.toString(), equals(Chain.parse(vmTrace.toString()).toString())); + }); + + test('thrown in a microtask', () { + return captureFuture(() => inMicrotask(() => throw 'error')) + .then((chain) { + // Since there was only one asynchronous operation, there should be only + // two traces in the chain. + expect(chain.traces, hasLength(2)); + + // The first frame of the first trace should be the line on which the + // actual error was thrown. + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + + // The second trace should describe the stack when the error callback + // was scheduled. + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inMicrotask')))); + }); + }); + + test('thrown in a one-shot timer', () { + return captureFuture(() => inOneShotTimer(() => throw 'error')) + .then((chain) { + expect(chain.traces, hasLength(2)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inOneShotTimer')))); + }); + }); + + test('thrown in a periodic timer', () { + return captureFuture(() => inPeriodicTimer(() => throw 'error')) + .then((chain) { + expect(chain.traces, hasLength(2)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inPeriodicTimer')))); + }); + }); + + test('thrown in a nested series of asynchronous operations', () { + return captureFuture(() { + inPeriodicTimer(() { + inOneShotTimer(() => inMicrotask(() => throw 'error')); + }); + }).then((chain) { + expect(chain.traces, hasLength(4)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inMicrotask')))); + expect(chain.traces[2].frames, + contains(frameMember(startsWith('inOneShotTimer')))); + expect(chain.traces[3].frames, + contains(frameMember(startsWith('inPeriodicTimer')))); + }); + }); + + test('thrown in a long future chain', () { + return captureFuture(() => inFutureChain(() => throw 'error')) + .then((chain) { + // Despite many asynchronous operations, there's only one level of + // nested calls, so there should be only two traces in the chain. This + // is important; programmers expect stack trace memory consumption to be + // O(depth of program), not O(length of program). + expect(chain.traces, hasLength(2)); + + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inFutureChain')))); + }); + }); + + test('thrown in new Future()', () { + return captureFuture(() => inNewFuture(() => throw 'error')) + .then((chain) { + expect(chain.traces, hasLength(3)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + + // The second trace is the one captured by + // [StackZoneSpecification.errorCallback]. Because that runs + // asynchronously within [new Future], it doesn't actually refer to the + // source file at all. + expect(chain.traces[1].frames, + everyElement(frameLibrary(isNot(contains('chain_test'))))); + + expect(chain.traces[2].frames, + contains(frameMember(startsWith('inNewFuture')))); + }); + }); + + test('thrown in new Future.sync()', () { + return captureFuture(() { + inMicrotask(() => inSyncFuture(() => throw 'error')); + }).then((chain) { + expect(chain.traces, hasLength(3)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inSyncFuture')))); + expect(chain.traces[2].frames, + contains(frameMember(startsWith('inMicrotask')))); + }); + }); + + test('multiple times', () { + var completer = Completer(); + var first = true; + + Chain.capture(() { + inMicrotask(() => throw 'first error'); + inPeriodicTimer(() => throw 'second error'); + }, onError: (error, chain) { + try { + if (first) { + expect(error, equals('first error')); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inMicrotask')))); + first = false; + } else { + expect(error, equals('second error')); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inPeriodicTimer')))); + completer.complete(); + } + } on Object catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + + return completer.future; + }); + + test('passed to a completer', () { + var trace = Trace.current(); + return captureFuture(() { + inMicrotask(() => completerErrorFuture(trace)); + }).then((chain) { + expect(chain.traces, hasLength(3)); + + // The first trace is the trace that was manually reported for the + // error. + expect(chain.traces.first.toString(), equals(trace.toString())); + + // The second trace is the trace that was captured when + // [Completer.addError] was called. + expect(chain.traces[1].frames, + contains(frameMember(startsWith('completerErrorFuture')))); + + // The third trace is the automatically-captured trace from when the + // microtask was scheduled. + expect(chain.traces[2].frames, + contains(frameMember(startsWith('inMicrotask')))); + }); + }); + + test('passed to a completer with no stack trace', () { + return captureFuture(() { + inMicrotask(completerErrorFuture); + }).then((chain) { + expect(chain.traces, hasLength(2)); + + // The first trace is the one captured when [Completer.addError] was + // called. + expect(chain.traces[0].frames, + contains(frameMember(startsWith('completerErrorFuture')))); + + // The second trace is the automatically-captured trace from when the + // microtask was scheduled. + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inMicrotask')))); + }); + }); + + test('passed to a stream controller', () { + var trace = Trace.current(); + return captureFuture(() { + inMicrotask(() => controllerErrorStream(trace).listen(null)); + }).then((chain) { + expect(chain.traces, hasLength(3)); + expect(chain.traces.first.toString(), equals(trace.toString())); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('controllerErrorStream')))); + expect(chain.traces[2].frames, + contains(frameMember(startsWith('inMicrotask')))); + }); + }); + + test('passed to a stream controller with no stack trace', () { + return captureFuture(() { + inMicrotask(() => controllerErrorStream().listen(null)); + }).then((chain) { + expect(chain.traces, hasLength(2)); + expect(chain.traces[0].frames, + contains(frameMember(startsWith('controllerErrorStream')))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inMicrotask')))); + }); + }); + + test('and relays them to the parent zone', () { + var completer = Completer(); + + runZonedGuarded(() { + Chain.capture(() { + inMicrotask(() => throw 'error'); + }, onError: (error, chain) { + expect(error, equals('error')); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inMicrotask')))); + throw error; + }); + }, (error, chain) { + try { + expect(error, equals('error')); + expect( + chain, + isA().having((c) => c.traces[1].frames, 'traces[1].frames', + contains(frameMember(startsWith('inMicrotask'))))); + completer.complete(); + } on Object catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + + return completer.future; + }); + }); + + test('capture() without onError passes exceptions to parent zone', () { + var completer = Completer(); + + runZonedGuarded(() { + Chain.capture(() => inMicrotask(() => throw 'error')); + }, (error, chain) { + try { + expect(error, equals('error')); + expect( + chain, + isA().having((c) => c.traces[1].frames, 'traces[1].frames', + contains(frameMember(startsWith('inMicrotask'))))); + completer.complete(); + } on Object catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + + return completer.future; + }); + + group('current() within capture()', () { + test('called in a microtask', () { + var completer = Completer(); + Chain.capture(() { + inMicrotask(() => completer.complete(Chain.current())); + }); + + return completer.future.then((chain) { + expect(chain.traces, hasLength(2)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inMicrotask')))); + }); + }); + + test('called in a one-shot timer', () { + var completer = Completer(); + Chain.capture(() { + inOneShotTimer(() => completer.complete(Chain.current())); + }); + + return completer.future.then((chain) { + expect(chain.traces, hasLength(2)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inOneShotTimer')))); + }); + }); + + test('called in a periodic timer', () { + var completer = Completer(); + Chain.capture(() { + inPeriodicTimer(() => completer.complete(Chain.current())); + }); + + return completer.future.then((chain) { + expect(chain.traces, hasLength(2)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inPeriodicTimer')))); + }); + }); + + test('called in a nested series of asynchronous operations', () { + var completer = Completer(); + Chain.capture(() { + inPeriodicTimer(() { + inOneShotTimer(() { + inMicrotask(() => completer.complete(Chain.current())); + }); + }); + }); + + return completer.future.then((chain) { + expect(chain.traces, hasLength(4)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inMicrotask')))); + expect(chain.traces[2].frames, + contains(frameMember(startsWith('inOneShotTimer')))); + expect(chain.traces[3].frames, + contains(frameMember(startsWith('inPeriodicTimer')))); + }); + }); + + test('called in a long future chain', () { + var completer = Completer(); + Chain.capture(() { + inFutureChain(() => completer.complete(Chain.current())); + }); + + return completer.future.then((chain) { + expect(chain.traces, hasLength(2)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('inFutureChain')))); + }); + }); + }); + + test( + 'current() outside of capture() returns a chain wrapping the current ' + 'trace', () { + // The test runner runs all tests with chains enabled. + return Chain.disable(() { + var completer = Completer(); + inMicrotask(() => completer.complete(Chain.current())); + + return completer.future.then((chain) { + // Since the chain wasn't loaded within [Chain.capture], the full stack + // chain isn't available and it just returns the current stack when + // called. + expect(chain.traces, hasLength(1)); + expect( + chain.traces.first.frames.first, frameMember(startsWith('main'))); + }); + }); + }); + + group('forTrace() within capture()', () { + test('called for a stack trace from a microtask', () { + return Chain.capture(() { + return chainForTrace(inMicrotask, () => throw 'error'); + }).then((chain) { + // Because [chainForTrace] has to set up a future chain to capture the + // stack trace while still showing it to the zone specification, it adds + // an additional level of async nesting and so an additional trace. + expect(chain.traces, hasLength(3)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('chainForTrace')))); + expect(chain.traces[2].frames, + contains(frameMember(startsWith('inMicrotask')))); + }); + }); + + test('called for a stack trace from a one-shot timer', () { + return Chain.capture(() { + return chainForTrace(inOneShotTimer, () => throw 'error'); + }).then((chain) { + expect(chain.traces, hasLength(3)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('chainForTrace')))); + expect(chain.traces[2].frames, + contains(frameMember(startsWith('inOneShotTimer')))); + }); + }); + + test('called for a stack trace from a periodic timer', () { + return Chain.capture(() { + return chainForTrace(inPeriodicTimer, () => throw 'error'); + }).then((chain) { + expect(chain.traces, hasLength(3)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('chainForTrace')))); + expect(chain.traces[2].frames, + contains(frameMember(startsWith('inPeriodicTimer')))); + }); + }); + + test( + 'called for a stack trace from a nested series of asynchronous ' + 'operations', () { + return Chain.capture(() { + return chainForTrace((callback) { + inPeriodicTimer(() => inOneShotTimer(() => inMicrotask(callback))); + }, () => throw 'error'); + }).then((chain) { + expect(chain.traces, hasLength(5)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('chainForTrace')))); + expect(chain.traces[2].frames, + contains(frameMember(startsWith('inMicrotask')))); + expect(chain.traces[3].frames, + contains(frameMember(startsWith('inOneShotTimer')))); + expect(chain.traces[4].frames, + contains(frameMember(startsWith('inPeriodicTimer')))); + }); + }); + + test('called for a stack trace from a long future chain', () { + return Chain.capture(() { + return chainForTrace(inFutureChain, () => throw 'error'); + }).then((chain) { + expect(chain.traces, hasLength(3)); + expect(chain.traces[0].frames.first, frameMember(startsWith('main'))); + expect(chain.traces[1].frames, + contains(frameMember(startsWith('chainForTrace')))); + expect(chain.traces[2].frames, + contains(frameMember(startsWith('inFutureChain')))); + }); + }); + + test('called for an unregistered stack trace uses the current chain', + () async { + late StackTrace trace; + var chain = await Chain.capture(() async { + try { + throw 'error'; + } catch (_, stackTrace) { + trace = stackTrace; + return Chain.forTrace(stackTrace); + } + }); + + expect(chain.traces, hasLength(greaterThan(1))); + + // Assert that we've trimmed the VM's stack chains here to avoid + // duplication. + expect(chain.traces.first.toString(), + equals(Chain.parse(trace.toString()).traces.first.toString())); + }); + }); + + test( + 'forTrace() outside of capture() returns a chain describing the VM stack ' + 'chain', () { + // Disable the test package's chain-tracking. + return Chain.disable(() async { + late StackTrace trace; + await Chain.capture(() async { + try { + throw 'error'; + } catch (_, stackTrace) { + trace = stackTrace; + } + }); + + final chain = Chain.forTrace(trace); + final traceStr = trace.toString(); + final gaps = vmChainGap.allMatches(traceStr); + // If the trace ends on a gap, there's no sub-trace following the gap. + final expectedLength = + (gaps.last.end == traceStr.length) ? gaps.length : gaps.length + 1; + expect(chain.traces, hasLength(expectedLength)); + expect( + chain.traces.first.frames, contains(frameMember(startsWith('main')))); + }); + }); +} diff --git a/pkgs/stack_trace/test/frame_test.dart b/pkgs/stack_trace/test/frame_test.dart new file mode 100644 index 000000000..a5dfc2064 --- /dev/null +++ b/pkgs/stack_trace/test/frame_test.dart @@ -0,0 +1,729 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:path/path.dart' as path; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/test.dart'; + +void main() { + group('.parseVM', () { + test('parses a stack frame with column correctly', () { + var frame = Frame.parseVM('#1 Foo._bar ' + '(file:///home/nweiz/code/stuff.dart:42:21)'); + expect( + frame.uri, equals(Uri.parse('file:///home/nweiz/code/stuff.dart'))); + expect(frame.line, equals(42)); + expect(frame.column, equals(21)); + expect(frame.member, equals('Foo._bar')); + }); + + test('parses a stack frame without column correctly', () { + var frame = Frame.parseVM('#1 Foo._bar ' + '(file:///home/nweiz/code/stuff.dart:24)'); + expect( + frame.uri, equals(Uri.parse('file:///home/nweiz/code/stuff.dart'))); + expect(frame.line, equals(24)); + expect(frame.column, null); + expect(frame.member, equals('Foo._bar')); + }); + + // This can happen with async stack traces. See issue 22009. + test('parses a stack frame without line or column correctly', () { + var frame = Frame.parseVM('#1 Foo._bar ' + '(file:///home/nweiz/code/stuff.dart)'); + expect( + frame.uri, equals(Uri.parse('file:///home/nweiz/code/stuff.dart'))); + expect(frame.line, isNull); + expect(frame.column, isNull); + expect(frame.member, equals('Foo._bar')); + }); + + test('converts "" to ""', () { + String? parsedMember(String member) => + Frame.parseVM('#0 $member (foo:0:0)').member; + + expect(parsedMember('Foo.'), equals('Foo.')); + expect(parsedMember('..bar'), + equals('..bar')); + }); + + test('converts "<_async_body>" to ""', () { + var frame = + Frame.parseVM('#0 Foo.<_async_body> (foo:0:0)'); + expect(frame.member, equals('Foo.')); + }); + + test('converts "" to ""', () { + var frame = Frame.parseVM('#0 Foo. (foo:0:0)'); + expect(frame.member, equals('Foo.')); + }); + + test('parses a folded frame correctly', () { + var frame = Frame.parseVM('...'); + + expect(frame.member, equals('...')); + expect(frame.uri, equals(Uri())); + expect(frame.line, isNull); + expect(frame.column, isNull); + }); + }); + + group('.parseV8', () { + test('returns an UnparsedFrame for malformed frames', () { + expectIsUnparsed(Frame.parseV8, ''); + expectIsUnparsed(Frame.parseV8, '#1'); + expectIsUnparsed(Frame.parseV8, '#1 Foo'); + expectIsUnparsed(Frame.parseV8, '#1 (dart:async/future.dart:10:15)'); + expectIsUnparsed(Frame.parseV8, 'Foo (dart:async/future.dart:10:15)'); + }); + + test('parses a stack frame correctly', () { + var frame = Frame.parseV8(' at VW.call\$0 ' + '(https://example.com/stuff.dart.js:560:28)'); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, equals(28)); + expect(frame.member, equals('VW.call\$0')); + }); + + test('parses a stack frame with a : in the authority', () { + var frame = Frame.parseV8(' at VW.call\$0 ' + '(http://localhost:8080/stuff.dart.js:560:28)'); + expect( + frame.uri, equals(Uri.parse('http://localhost:8080/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, equals(28)); + expect(frame.member, equals('VW.call\$0')); + }); + + test('parses a stack frame with an absolute POSIX path correctly', () { + var frame = Frame.parseV8(' at VW.call\$0 ' + '(/path/to/stuff.dart.js:560:28)'); + expect(frame.uri, equals(Uri.parse('file:///path/to/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, equals(28)); + expect(frame.member, equals('VW.call\$0')); + }); + + test('parses a stack frame with an absolute Windows path correctly', () { + var frame = Frame.parseV8(' at VW.call\$0 ' + r'(C:\path\to\stuff.dart.js:560:28)'); + expect(frame.uri, equals(Uri.parse('file:///C:/path/to/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, equals(28)); + expect(frame.member, equals('VW.call\$0')); + }); + + test('parses a stack frame with a Windows UNC path correctly', () { + var frame = Frame.parseV8(' at VW.call\$0 ' + r'(\\mount\path\to\stuff.dart.js:560:28)'); + expect( + frame.uri, equals(Uri.parse('file://mount/path/to/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, equals(28)); + expect(frame.member, equals('VW.call\$0')); + }); + + test('parses a stack frame with a relative POSIX path correctly', () { + var frame = Frame.parseV8(' at VW.call\$0 ' + '(path/to/stuff.dart.js:560:28)'); + expect(frame.uri, equals(Uri.parse('path/to/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, equals(28)); + expect(frame.member, equals('VW.call\$0')); + }); + + test('parses a stack frame with a relative Windows path correctly', () { + var frame = Frame.parseV8(' at VW.call\$0 ' + r'(path\to\stuff.dart.js:560:28)'); + expect(frame.uri, equals(Uri.parse('path/to/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, equals(28)); + expect(frame.member, equals('VW.call\$0')); + }); + + test('parses an anonymous stack frame correctly', () { + var frame = + Frame.parseV8(' at https://example.com/stuff.dart.js:560:28'); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, equals(28)); + expect(frame.member, equals('')); + }); + + test('parses a native stack frame correctly', () { + var frame = Frame.parseV8(' at Object.stringify (native)'); + expect(frame.uri, Uri.parse('native')); + expect(frame.line, isNull); + expect(frame.column, isNull); + expect(frame.member, equals('Object.stringify')); + }); + + test('parses a stack frame with [as ...] correctly', () { + // Ignore "[as ...]", since other stack trace formats don't support a + // similar construct. + var frame = Frame.parseV8(' at VW.call\$0 [as call\$4] ' + '(https://example.com/stuff.dart.js:560:28)'); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, equals(28)); + expect(frame.member, equals('VW.call\$0')); + }); + + test('parses a basic eval stack frame correctly', () { + var frame = Frame.parseV8(' at eval (eval at ' + '(https://example.com/stuff.dart.js:560:28))'); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, equals(28)); + expect(frame.member, equals('eval')); + }); + + test('parses an IE10 eval stack frame correctly', () { + var frame = Frame.parseV8(' at eval (eval at Anonymous function ' + '(https://example.com/stuff.dart.js:560:28))'); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, equals(28)); + expect(frame.member, equals('eval')); + }); + + test('parses an eval stack frame with inner position info correctly', () { + var frame = Frame.parseV8(' at eval (eval at ' + '(https://example.com/stuff.dart.js:560:28), :3:28)'); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, equals(28)); + expect(frame.member, equals('eval')); + }); + + test('parses a nested eval stack frame correctly', () { + var frame = Frame.parseV8(' at eval (eval at ' + '(eval at sub (https://example.com/stuff.dart.js:560:28)))'); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, equals(28)); + expect(frame.member, equals('eval')); + }); + + test('converts "" to ""', () { + String? parsedMember(String member) => + Frame.parseV8(' at $member (foo:0:0)').member; + + expect(parsedMember('Foo.'), equals('Foo.')); + expect( + parsedMember('..bar'), equals('..bar')); + }); + + test('returns an UnparsedFrame for malformed frames', () { + expectIsUnparsed(Frame.parseV8, ''); + expectIsUnparsed(Frame.parseV8, ' at'); + expectIsUnparsed(Frame.parseV8, ' at Foo'); + expectIsUnparsed(Frame.parseV8, ' at Foo (dart:async/future.dart)'); + expectIsUnparsed(Frame.parseV8, ' at (dart:async/future.dart:10:15)'); + expectIsUnparsed(Frame.parseV8, 'Foo (dart:async/future.dart:10:15)'); + expectIsUnparsed(Frame.parseV8, ' at dart:async/future.dart'); + expectIsUnparsed(Frame.parseV8, 'dart:async/future.dart:10:15'); + }); + }); + + group('.parseFirefox/.parseSafari', () { + test('parses a Firefox stack trace with anonymous function', () { + var trace = Trace.parse(''' +Foo._bar@https://example.com/stuff.js:18056:12 +anonymous/<@https://example.com/stuff.js line 693 > Function:3:40 +baz@https://pub.dev/buz.js:56355:55 + '''); + expect(trace.frames[0].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[0].line, equals(18056)); + expect(trace.frames[0].column, equals(12)); + expect(trace.frames[0].member, equals('Foo._bar')); + expect(trace.frames[1].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[1].line, equals(693)); + expect(trace.frames[1].column, isNull); + expect(trace.frames[1].member, equals('')); + expect(trace.frames[2].uri, equals(Uri.parse('https://pub.dev/buz.js'))); + expect(trace.frames[2].line, equals(56355)); + expect(trace.frames[2].column, equals(55)); + expect(trace.frames[2].member, equals('baz')); + }); + + test('parses a Firefox stack trace with nested evals in anonymous function', + () { + var trace = Trace.parse(''' + Foo._bar@https://example.com/stuff.js:18056:12 + anonymous@file:///C:/example.html line 7 > eval line 1 > eval:1:1 + anonymous@file:///C:/example.html line 45 > Function:1:1 + '''); + expect(trace.frames[0].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[0].line, equals(18056)); + expect(trace.frames[0].column, equals(12)); + expect(trace.frames[0].member, equals('Foo._bar')); + expect(trace.frames[1].uri, equals(Uri.parse('file:///C:/example.html'))); + expect(trace.frames[1].line, equals(7)); + expect(trace.frames[1].column, isNull); + expect(trace.frames[1].member, equals('')); + expect(trace.frames[2].uri, equals(Uri.parse('file:///C:/example.html'))); + expect(trace.frames[2].line, equals(45)); + expect(trace.frames[2].column, isNull); + expect(trace.frames[2].member, equals('')); + }); + + test('parses a simple stack frame correctly', () { + var frame = Frame.parseFirefox( + '.VW.call\$0@https://example.com/stuff.dart.js:560'); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, isNull); + expect(frame.member, equals('VW.call\$0')); + }); + + test('parses a stack frame with an absolute POSIX path correctly', () { + var frame = Frame.parseFirefox('.VW.call\$0@/path/to/stuff.dart.js:560'); + expect(frame.uri, equals(Uri.parse('file:///path/to/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, isNull); + expect(frame.member, equals('VW.call\$0')); + }); + + test('parses a stack frame with an absolute Windows path correctly', () { + var frame = + Frame.parseFirefox(r'.VW.call$0@C:\path\to\stuff.dart.js:560'); + expect(frame.uri, equals(Uri.parse('file:///C:/path/to/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, isNull); + expect(frame.member, equals('VW.call\$0')); + }); + + test('parses a stack frame with a Windows UNC path correctly', () { + var frame = + Frame.parseFirefox(r'.VW.call$0@\\mount\path\to\stuff.dart.js:560'); + expect( + frame.uri, equals(Uri.parse('file://mount/path/to/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, isNull); + expect(frame.member, equals('VW.call\$0')); + }); + + test('parses a stack frame with a relative POSIX path correctly', () { + var frame = Frame.parseFirefox('.VW.call\$0@path/to/stuff.dart.js:560'); + expect(frame.uri, equals(Uri.parse('path/to/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, isNull); + expect(frame.member, equals('VW.call\$0')); + }); + + test('parses a stack frame with a relative Windows path correctly', () { + var frame = Frame.parseFirefox(r'.VW.call$0@path\to\stuff.dart.js:560'); + expect(frame.uri, equals(Uri.parse('path/to/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, isNull); + expect(frame.member, equals('VW.call\$0')); + }); + + test('parses a simple anonymous stack frame correctly', () { + var frame = Frame.parseFirefox('@https://example.com/stuff.dart.js:560'); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, isNull); + expect(frame.member, equals('')); + }); + + test('parses a nested anonymous stack frame correctly', () { + var frame = + Frame.parseFirefox('.foo/<@https://example.com/stuff.dart.js:560'); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, isNull); + expect(frame.member, equals('foo.')); + + frame = Frame.parseFirefox('.foo/@https://example.com/stuff.dart.js:560'); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, isNull); + expect(frame.member, equals('foo.')); + }); + + test('parses a named nested anonymous stack frame correctly', () { + var frame = Frame.parseFirefox( + '.foo/.name<@https://example.com/stuff.dart.js:560'); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, isNull); + expect(frame.member, equals('foo.')); + + frame = Frame.parseFirefox( + '.foo/.name@https://example.com/stuff.dart.js:560'); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, isNull); + expect(frame.member, equals('foo.')); + }); + + test('parses a stack frame with parameters correctly', () { + var frame = Frame.parseFirefox( + '.foo(12, "@)()/<")@https://example.com/stuff.dart.js:560'); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, isNull); + expect(frame.member, equals('foo')); + }); + + test('parses a nested anonymous stack frame with parameters correctly', () { + var frame = Frame.parseFirefox( + '.foo(12, "@)()/<")/.fn<@https://example.com/stuff.dart.js:560', + ); + expect(frame.uri, equals(Uri.parse('https://example.com/stuff.dart.js'))); + expect(frame.line, equals(560)); + expect(frame.column, isNull); + expect(frame.member, equals('foo.')); + }); + + test( + 'parses a deeply-nested anonymous stack frame with parameters ' + 'correctly', () { + var frame = Frame.parseFirefox('.convertDartClosureToJS/\$function.')); + }); + + test('returns an UnparsedFrame for malformed frames', () { + expectIsUnparsed(Frame.parseFirefox, ''); + expectIsUnparsed(Frame.parseFirefox, '.foo'); + expectIsUnparsed(Frame.parseFirefox, '.foo@dart:async/future.dart'); + expectIsUnparsed(Frame.parseFirefox, '.foo(@dart:async/future.dart:10'); + expectIsUnparsed(Frame.parseFirefox, '@dart:async/future.dart'); + }); + + test('parses a simple stack frame correctly', () { + var frame = + Frame.parseFirefox('foo\$bar@https://dart.dev/foo/bar.dart:10:11'); + expect(frame.uri, equals(Uri.parse('https://dart.dev/foo/bar.dart'))); + expect(frame.line, equals(10)); + expect(frame.column, equals(11)); + expect(frame.member, equals('foo\$bar')); + }); + + test('parses an anonymous stack frame correctly', () { + var frame = Frame.parseFirefox('https://dart.dev/foo/bar.dart:10:11'); + expect(frame.uri, equals(Uri.parse('https://dart.dev/foo/bar.dart'))); + expect(frame.line, equals(10)); + expect(frame.column, equals(11)); + expect(frame.member, equals('')); + }); + + test('parses a stack frame with no line correctly', () { + var frame = + Frame.parseFirefox('foo\$bar@https://dart.dev/foo/bar.dart::11'); + expect(frame.uri, equals(Uri.parse('https://dart.dev/foo/bar.dart'))); + expect(frame.line, isNull); + expect(frame.column, equals(11)); + expect(frame.member, equals('foo\$bar')); + }); + + test('parses a stack frame with no column correctly', () { + var frame = + Frame.parseFirefox('foo\$bar@https://dart.dev/foo/bar.dart:10:'); + expect(frame.uri, equals(Uri.parse('https://dart.dev/foo/bar.dart'))); + expect(frame.line, equals(10)); + expect(frame.column, isNull); + expect(frame.member, equals('foo\$bar')); + }); + + test('parses a stack frame with no line or column correctly', () { + var frame = + Frame.parseFirefox('foo\$bar@https://dart.dev/foo/bar.dart:10:11'); + expect(frame.uri, equals(Uri.parse('https://dart.dev/foo/bar.dart'))); + expect(frame.line, equals(10)); + expect(frame.column, equals(11)); + expect(frame.member, equals('foo\$bar')); + }); + }); + + group('.parseFriendly', () { + test('parses a simple stack frame correctly', () { + var frame = Frame.parseFriendly( + 'https://dart.dev/foo/bar.dart 10:11 Foo..bar'); + expect(frame.uri, equals(Uri.parse('https://dart.dev/foo/bar.dart'))); + expect(frame.line, equals(10)); + expect(frame.column, equals(11)); + expect(frame.member, equals('Foo..bar')); + }); + + test('parses a stack frame with no line or column correctly', () { + var frame = + Frame.parseFriendly('https://dart.dev/foo/bar.dart Foo..bar'); + expect(frame.uri, equals(Uri.parse('https://dart.dev/foo/bar.dart'))); + expect(frame.line, isNull); + expect(frame.column, isNull); + expect(frame.member, equals('Foo..bar')); + }); + + test('parses a stack frame with no column correctly', () { + var frame = + Frame.parseFriendly('https://dart.dev/foo/bar.dart 10 Foo..bar'); + expect(frame.uri, equals(Uri.parse('https://dart.dev/foo/bar.dart'))); + expect(frame.line, equals(10)); + expect(frame.column, isNull); + expect(frame.member, equals('Foo..bar')); + }); + + test('parses a stack frame with a relative path correctly', () { + var frame = Frame.parseFriendly('foo/bar.dart 10:11 Foo..bar'); + expect(frame.uri, + equals(path.toUri(path.absolute(path.join('foo', 'bar.dart'))))); + expect(frame.line, equals(10)); + expect(frame.column, equals(11)); + expect(frame.member, equals('Foo..bar')); + }); + + test('returns an UnparsedFrame for malformed frames', () { + expectIsUnparsed(Frame.parseFriendly, ''); + expectIsUnparsed(Frame.parseFriendly, 'foo/bar.dart'); + expectIsUnparsed(Frame.parseFriendly, 'foo/bar.dart 10:11'); + }); + + test('parses a data url stack frame with no line or column correctly', () { + var frame = Frame.parseFriendly('data:... main'); + expect(frame.uri.scheme, equals('data')); + expect(frame.line, isNull); + expect(frame.column, isNull); + expect(frame.member, equals('main')); + }); + + test('parses a data url stack frame correctly', () { + var frame = Frame.parseFriendly('data:... 10:11 main'); + expect(frame.uri.scheme, equals('data')); + expect(frame.line, equals(10)); + expect(frame.column, equals(11)); + expect(frame.member, equals('main')); + }); + + test('parses a stack frame with spaces in the member name correctly', () { + var frame = Frame.parseFriendly( + 'foo/bar.dart 10:11 (anonymous function).dart.fn'); + expect(frame.uri, + equals(path.toUri(path.absolute(path.join('foo', 'bar.dart'))))); + expect(frame.line, equals(10)); + expect(frame.column, equals(11)); + expect(frame.member, equals('(anonymous function).dart.fn')); + }); + + test( + 'parses a stack frame with spaces in the member name and no line or ' + 'column correctly', () { + var frame = Frame.parseFriendly( + 'https://dart.dev/foo/bar.dart (anonymous function).dart.fn'); + expect(frame.uri, equals(Uri.parse('https://dart.dev/foo/bar.dart'))); + expect(frame.line, isNull); + expect(frame.column, isNull); + expect(frame.member, equals('(anonymous function).dart.fn')); + }); + }); + + test('only considers dart URIs to be core', () { + bool isCore(String library) => + Frame.parseVM('#0 Foo ($library:0:0)').isCore; + + expect(isCore('dart:core'), isTrue); + expect(isCore('dart:async'), isTrue); + expect(isCore('dart:core/uri.dart'), isTrue); + expect(isCore('dart:async/future.dart'), isTrue); + expect(isCore('bart:core'), isFalse); + expect(isCore('sdart:core'), isFalse); + expect(isCore('darty:core'), isFalse); + expect(isCore('bart:core/uri.dart'), isFalse); + }); + + group('.library', () { + test('returns the URI string for non-file URIs', () { + expect(Frame.parseVM('#0 Foo (dart:async/future.dart:0:0)').library, + equals('dart:async/future.dart')); + expect( + Frame.parseVM('#0 Foo ' + '(https://dart.dev/stuff/thing.dart:0:0)') + .library, + equals('https://dart.dev/stuff/thing.dart')); + }); + + test('returns the relative path for file URIs', () { + expect(Frame.parseVM('#0 Foo (foo/bar.dart:0:0)').library, + equals(path.join('foo', 'bar.dart'))); + }); + + test('truncates legacy data: URIs', () { + var frame = Frame.parseVM( + '#0 Foo (data:application/dart;charset=utf-8,blah:0:0)'); + expect(frame.library, equals('data:...')); + }); + + test('truncates data: URIs', () { + var frame = Frame.parseVM( + '#0 main (:1:15)'); + expect(frame.library, equals('data:...')); + }); + }); + + group('.location', () { + test( + 'returns the library and line/column numbers for non-core ' + 'libraries', () { + expect( + Frame.parseVM('#0 Foo ' + '(https://dart.dev/thing.dart:5:10)') + .location, + equals('https://dart.dev/thing.dart 5:10')); + expect(Frame.parseVM('#0 Foo (foo/bar.dart:1:2)').location, + equals('${path.join('foo', 'bar.dart')} 1:2')); + }); + }); + + group('.package', () { + test('returns null for non-package URIs', () { + expect( + Frame.parseVM('#0 Foo (dart:async/future.dart:0:0)').package, isNull); + expect( + Frame.parseVM('#0 Foo ' + '(https://dart.dev/stuff/thing.dart:0:0)') + .package, + isNull); + }); + + test('returns the package name for package: URIs', () { + expect(Frame.parseVM('#0 Foo (package:foo/foo.dart:0:0)').package, + equals('foo')); + expect(Frame.parseVM('#0 Foo (package:foo/zap/bar.dart:0:0)').package, + equals('foo')); + }); + }); + + group('.toString()', () { + test( + 'returns the library and line/column numbers for non-core ' + 'libraries', () { + expect( + Frame.parseVM('#0 Foo (https://dart.dev/thing.dart:5:10)').toString(), + equals('https://dart.dev/thing.dart 5:10 in Foo')); + }); + + test('converts "" to ""', () { + expect( + Frame.parseVM('#0 Foo. ' + '(dart:core/uri.dart:5:10)') + .toString(), + equals('dart:core/uri.dart 5:10 in Foo.')); + }); + + test('prints a frame without a column correctly', () { + expect(Frame.parseVM('#0 Foo (dart:core/uri.dart:5)').toString(), + equals('dart:core/uri.dart 5 in Foo')); + }); + + test('prints relative paths as relative', () { + var relative = path.normalize('relative/path/to/foo.dart'); + expect(Frame.parseFriendly('$relative 5:10 Foo').toString(), + equals('$relative 5:10 in Foo')); + }); + }); + + test('parses a V8 Wasm frame with a name', () { + var frame = Frame.parseV8(' at Error._throwWithCurrentStackTrace ' + '(wasm://wasm/0006d966:wasm-function[119]:0xbb13)'); + expect(frame.uri, Uri.parse('wasm://wasm/0006d966')); + expect(frame.line, 1); + expect(frame.column, 0xbb13 + 1); + expect(frame.member, 'Error._throwWithCurrentStackTrace'); + }); + + test('parses a V8 Wasm frame with a name with spaces', () { + var frame = Frame.parseV8(' at main tear-off trampoline ' + '(wasm://wasm/0017fbea:wasm-function[863]:0x23cc8)'); + expect(frame.uri, Uri.parse('wasm://wasm/0017fbea')); + expect(frame.line, 1); + expect(frame.column, 0x23cc8 + 1); + expect(frame.member, 'main tear-off trampoline'); + }); + + test('parses a V8 Wasm frame with a name with colons and parens', () { + var frame = Frame.parseV8(' at a::b::c() ' + '(https://a.b.com/x/y/z.wasm:wasm-function[66334]:0x12c28ad)'); + expect(frame.uri, Uri.parse('https://a.b.com/x/y/z.wasm')); + expect(frame.line, 1); + expect(frame.column, 0x12c28ad + 1); + expect(frame.member, 'a::b::c()'); + }); + + test('parses a V8 Wasm frame without a name', () { + var frame = + Frame.parseV8(' at wasm://wasm/0006d966:wasm-function[119]:0xbb13'); + expect(frame.uri, Uri.parse('wasm://wasm/0006d966')); + expect(frame.line, 1); + expect(frame.column, 0xbb13 + 1); + expect(frame.member, '119'); + }); + + test('parses a Firefox Wasm frame with a name', () { + var frame = Frame.parseFirefox( + 'g@http://localhost:8080/test.wasm:wasm-function[796]:0x143b4'); + expect(frame.uri, Uri.parse('http://localhost:8080/test.wasm')); + expect(frame.line, 1); + expect(frame.column, 0x143b4 + 1); + expect(frame.member, 'g'); + }); + + test('parses a Firefox Wasm frame with a name with spaces', () { + var frame = Frame.parseFirefox( + 'main tear-off trampoline@http://localhost:8080/test.wasm:wasm-function[794]:0x14387'); + expect(frame.uri, Uri.parse('http://localhost:8080/test.wasm')); + expect(frame.line, 1); + expect(frame.column, 0x14387 + 1); + expect(frame.member, 'main tear-off trampoline'); + }); + + test('parses a Firefox Wasm frame without a name', () { + var frame = Frame.parseFirefox( + '@http://localhost:8080/test.wasm:wasm-function[796]:0x143b4'); + expect(frame.uri, Uri.parse('http://localhost:8080/test.wasm')); + expect(frame.line, 1); + expect(frame.column, 0x143b4 + 1); + expect(frame.member, '796'); + }); + + test('parses a Safari Wasm frame with a name', () { + var frame = Frame.parseSafari('.wasm-function[g]@[wasm code]'); + expect(frame.uri, Uri.parse('wasm code')); + expect(frame.line, null); + expect(frame.column, null); + expect(frame.member, 'g'); + }); + + test('parses a Safari Wasm frame with a name', () { + var frame = Frame.parseSafari( + '.wasm-function[main tear-off trampoline]@[wasm code]'); + expect(frame.uri, Uri.parse('wasm code')); + expect(frame.line, null); + expect(frame.column, null); + expect(frame.member, 'main tear-off trampoline'); + }); + + test('parses a Safari Wasm frame without a name', () { + var frame = Frame.parseSafari('.wasm-function[796]@[wasm code]'); + expect(frame.uri, Uri.parse('wasm code')); + expect(frame.line, null); + expect(frame.column, null); + expect(frame.member, '796'); + }); +} + +void expectIsUnparsed(Frame Function(String) constructor, String text) { + var frame = constructor(text); + expect(frame, isA()); + expect(frame.toString(), equals(text)); +} diff --git a/pkgs/stack_trace/test/trace_test.dart b/pkgs/stack_trace/test/trace_test.dart new file mode 100644 index 000000000..e09de9555 --- /dev/null +++ b/pkgs/stack_trace/test/trace_test.dart @@ -0,0 +1,615 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:path/path.dart' as path; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/test.dart'; + +void main() { + // This just shouldn't crash. + test('a native stack trace is parseable', Trace.current); + + group('.parse', () { + test('.parse parses a V8 stack trace with eval statment correctly', () { + var trace = Trace.parse(r'''Error + at Object.eval (eval at Foo (main.dart.js:588), :3:47)'''); + expect(trace.frames[0].uri, Uri.parse('main.dart.js')); + expect(trace.frames[0].member, equals('Object.eval')); + expect(trace.frames[0].line, equals(588)); + expect(trace.frames[0].column, isNull); + }); + + test('.parse parses a VM stack trace correctly', () { + var trace = Trace.parse( + '#0 Foo._bar (file:///home/nweiz/code/stuff.dart:42:21)\n' + '#1 zip..zap (dart:async/future.dart:0:2)\n' + '#2 zip..zap (https://pub.dev/thing.dart:1:100)', + ); + + expect(trace.frames[0].uri, + equals(Uri.parse('file:///home/nweiz/code/stuff.dart'))); + expect(trace.frames[1].uri, equals(Uri.parse('dart:async/future.dart'))); + expect( + trace.frames[2].uri, equals(Uri.parse('https://pub.dev/thing.dart'))); + }); + + test('parses a V8 stack trace correctly', () { + var trace = Trace.parse('Error\n' + ' at Foo._bar (https://example.com/stuff.js:42:21)\n' + ' at https://example.com/stuff.js:0:2\n' + ' at zip..zap ' + '(https://pub.dev/thing.js:1:100)'); + + expect(trace.frames[0].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[1].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect( + trace.frames[2].uri, equals(Uri.parse('https://pub.dev/thing.js'))); + + trace = Trace.parse('Exception: foo\n' + ' at Foo._bar (https://example.com/stuff.js:42:21)\n' + ' at https://example.com/stuff.js:0:2\n' + ' at zip..zap ' + '(https://pub.dev/thing.js:1:100)'); + + expect(trace.frames[0].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[1].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect( + trace.frames[2].uri, equals(Uri.parse('https://pub.dev/thing.js'))); + + trace = Trace.parse('Exception: foo\n' + ' bar\n' + ' at Foo._bar (https://example.com/stuff.js:42:21)\n' + ' at https://example.com/stuff.js:0:2\n' + ' at zip..zap ' + '(https://pub.dev/thing.js:1:100)'); + + expect(trace.frames[0].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[1].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect( + trace.frames[2].uri, equals(Uri.parse('https://pub.dev/thing.js'))); + + trace = Trace.parse('Exception: foo\n' + ' bar\n' + ' at Foo._bar (https://example.com/stuff.js:42:21)\n' + ' at https://example.com/stuff.js:0:2\n' + ' at (anonymous function).zip.zap ' + '(https://pub.dev/thing.js:1:100)'); + + expect(trace.frames[0].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[1].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[1].member, equals('')); + expect( + trace.frames[2].uri, equals(Uri.parse('https://pub.dev/thing.js'))); + expect(trace.frames[2].member, equals('.zip.zap')); + }); + + // JavaScriptCore traces are just like V8, except that it doesn't have a + // header and it starts with a tab rather than spaces. + test('parses a JavaScriptCore stack trace correctly', () { + var trace = + Trace.parse('\tat Foo._bar (https://example.com/stuff.js:42:21)\n' + '\tat https://example.com/stuff.js:0:2\n' + '\tat zip..zap ' + '(https://pub.dev/thing.js:1:100)'); + + expect(trace.frames[0].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[1].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect( + trace.frames[2].uri, equals(Uri.parse('https://pub.dev/thing.js'))); + + trace = Trace.parse('\tat Foo._bar (https://example.com/stuff.js:42:21)\n' + '\tat \n' + '\tat zip..zap ' + '(https://pub.dev/thing.js:1:100)'); + + expect(trace.frames[0].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect( + trace.frames[1].uri, equals(Uri.parse('https://pub.dev/thing.js'))); + }); + + test('parses a Firefox/Safari stack trace correctly', () { + var trace = Trace.parse('Foo._bar@https://example.com/stuff.js:42\n' + 'zip/<@https://example.com/stuff.js:0\n' + 'zip.zap(12, "@)()/<")@https://pub.dev/thing.js:1'); + + expect(trace.frames[0].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[1].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect( + trace.frames[2].uri, equals(Uri.parse('https://pub.dev/thing.js'))); + + trace = Trace.parse('zip/<@https://example.com/stuff.js:0\n' + 'Foo._bar@https://example.com/stuff.js:42\n' + 'zip.zap(12, "@)()/<")@https://pub.dev/thing.js:1'); + + expect(trace.frames[0].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[1].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect( + trace.frames[2].uri, equals(Uri.parse('https://pub.dev/thing.js'))); + + trace = Trace.parse('zip.zap(12, "@)()/<")@https://pub.dev/thing.js:1\n' + 'zip/<@https://example.com/stuff.js:0\n' + 'Foo._bar@https://example.com/stuff.js:42'); + + expect( + trace.frames[0].uri, equals(Uri.parse('https://pub.dev/thing.js'))); + expect(trace.frames[1].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[2].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + }); + + test('parses a Firefox/Safari stack trace containing native code correctly', + () { + var trace = Trace.parse('Foo._bar@https://example.com/stuff.js:42\n' + 'zip/<@https://example.com/stuff.js:0\n' + 'zip.zap(12, "@)()/<")@https://pub.dev/thing.js:1\n' + '[native code]'); + + expect(trace.frames[0].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[1].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect( + trace.frames[2].uri, equals(Uri.parse('https://pub.dev/thing.js'))); + expect(trace.frames.length, equals(3)); + }); + + test('parses a Firefox/Safari stack trace without a method name correctly', + () { + var trace = Trace.parse('https://example.com/stuff.js:42\n' + 'zip/<@https://example.com/stuff.js:0\n' + 'zip.zap(12, "@)()/<")@https://pub.dev/thing.js:1'); + + expect(trace.frames[0].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[0].member, equals('')); + expect(trace.frames[1].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect( + trace.frames[2].uri, equals(Uri.parse('https://pub.dev/thing.js'))); + }); + + test('parses a Firefox/Safari stack trace with an empty line correctly', + () { + var trace = Trace.parse('Foo._bar@https://example.com/stuff.js:42\n' + '\n' + 'zip/<@https://example.com/stuff.js:0\n' + 'zip.zap(12, "@)()/<")@https://pub.dev/thing.js:1'); + + expect(trace.frames[0].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[1].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect( + trace.frames[2].uri, equals(Uri.parse('https://pub.dev/thing.js'))); + }); + + test('parses a Firefox/Safari stack trace with a column number correctly', + () { + var trace = Trace.parse('Foo._bar@https://example.com/stuff.js:42:2\n' + 'zip/<@https://example.com/stuff.js:0\n' + 'zip.zap(12, "@)()/<")@https://pub.dev/thing.js:1'); + + expect(trace.frames[0].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect(trace.frames[0].line, equals(42)); + expect(trace.frames[0].column, equals(2)); + expect(trace.frames[1].uri, + equals(Uri.parse('https://example.com/stuff.js'))); + expect( + trace.frames[2].uri, equals(Uri.parse('https://pub.dev/thing.js'))); + }); + + test('parses a package:stack_trace stack trace correctly', () { + var trace = + Trace.parse('https://dart.dev/foo/bar.dart 10:11 Foo..bar\n' + 'https://dart.dev/foo/baz.dart Foo..bar'); + + expect(trace.frames[0].uri, + equals(Uri.parse('https://dart.dev/foo/bar.dart'))); + expect(trace.frames[1].uri, + equals(Uri.parse('https://dart.dev/foo/baz.dart'))); + }); + + test('parses a package:stack_trace stack chain correctly', () { + var trace = + Trace.parse('https://dart.dev/foo/bar.dart 10:11 Foo..bar\n' + 'https://dart.dev/foo/baz.dart Foo..bar\n' + '===== asynchronous gap ===========================\n' + 'https://dart.dev/foo/bang.dart 10:11 Foo..bar\n' + 'https://dart.dev/foo/quux.dart Foo..bar'); + + expect(trace.frames[0].uri, + equals(Uri.parse('https://dart.dev/foo/bar.dart'))); + expect(trace.frames[1].uri, + equals(Uri.parse('https://dart.dev/foo/baz.dart'))); + expect(trace.frames[2].uri, + equals(Uri.parse('https://dart.dev/foo/bang.dart'))); + expect(trace.frames[3].uri, + equals(Uri.parse('https://dart.dev/foo/quux.dart'))); + }); + + test('parses a package:stack_trace stack chain with end gap correctly', () { + var trace = Trace.parse( + 'https://dart.dev/foo/bar.dart 10:11 Foo..bar\n' + 'https://dart.dev/foo/baz.dart Foo..bar\n' + 'https://dart.dev/foo/bang.dart 10:11 Foo..bar\n' + 'https://dart.dev/foo/quux.dart Foo..bar===== asynchronous gap ===========================\n', + ); + + expect(trace.frames.length, 4); + expect(trace.frames[0].uri, + equals(Uri.parse('https://dart.dev/foo/bar.dart'))); + expect(trace.frames[1].uri, + equals(Uri.parse('https://dart.dev/foo/baz.dart'))); + expect(trace.frames[2].uri, + equals(Uri.parse('https://dart.dev/foo/bang.dart'))); + expect(trace.frames[3].uri, + equals(Uri.parse('https://dart.dev/foo/quux.dart'))); + }); + + test('parses a real package:stack_trace stack trace correctly', () { + var traceString = Trace.current().toString(); + expect(Trace.parse(traceString).toString(), equals(traceString)); + }); + + test('parses an empty string correctly', () { + var trace = Trace.parse(''); + expect(trace.frames, isEmpty); + expect(trace.toString(), equals('')); + }); + + test('parses trace with async gap correctly', () { + var trace = Trace.parse('#0 bop (file:///pull.dart:42:23)\n' + '\n' + '#1 twist (dart:the/future.dart:0:2)\n' + '#2 main (dart:my/file.dart:4:6)\n'); + + expect(trace.frames.length, 3); + expect(trace.frames[0].uri, equals(Uri.parse('file:///pull.dart'))); + expect(trace.frames[1].uri, equals(Uri.parse('dart:the/future.dart'))); + expect(trace.frames[2].uri, equals(Uri.parse('dart:my/file.dart'))); + }); + + test('parses trace with async gap at end correctly', () { + var trace = Trace.parse('#0 bop (file:///pull.dart:42:23)\n' + '#1 twist (dart:the/future.dart:0:2)\n' + '\n'); + + expect(trace.frames.length, 2); + expect(trace.frames[0].uri, equals(Uri.parse('file:///pull.dart'))); + expect(trace.frames[1].uri, equals(Uri.parse('dart:the/future.dart'))); + }); + + test('parses a V8 stack frace with Wasm frames correctly', () { + var trace = Trace.parse( + '\tat Error._throwWithCurrentStackTrace (wasm://wasm/0006d892:wasm-function[119]:0xbaf8)\n' + '\tat main (wasm://wasm/0006d892:wasm-function[792]:0x14378)\n' + '\tat main tear-off trampoline (wasm://wasm/0006d892:wasm-function[794]:0x14387)\n' + '\tat _invokeMain (wasm://wasm/0006d892:wasm-function[70]:0xa56c)\n' + '\tat InstantiatedApp.invokeMain (/home/user/test.mjs:361:37)\n' + '\tat main (/home/user/run_wasm.js:416:21)\n' + '\tat async action (/home/user/run_wasm.js:353:38)\n' + '\tat async eventLoop (/home/user/run_wasm.js:329:9)'); + + expect(trace.frames.length, 8); + + for (final frame in trace.frames) { + expect(frame is UnparsedFrame, false); + } + + expect(trace.frames[0].uri, Uri.parse('wasm://wasm/0006d892')); + expect(trace.frames[0].line, 1); + expect(trace.frames[0].column, 0xbaf8 + 1); + expect(trace.frames[0].member, 'Error._throwWithCurrentStackTrace'); + + expect(trace.frames[4].uri, Uri.parse('file:///home/user/test.mjs')); + expect(trace.frames[4].line, 361); + expect(trace.frames[4].column, 37); + expect(trace.frames[4].member, 'InstantiatedApp.invokeMain'); + + expect(trace.frames[5].uri, Uri.parse('file:///home/user/run_wasm.js')); + expect(trace.frames[5].line, 416); + expect(trace.frames[5].column, 21); + expect(trace.frames[5].member, 'main'); + }); + + test('parses Firefox stack frace with Wasm frames correctly', () { + var trace = Trace.parse( + 'Error._throwWithCurrentStackTrace@http://localhost:8080/test.wasm:wasm-function[119]:0xbaf8\n' + 'main@http://localhost:8080/test.wasm:wasm-function[792]:0x14378\n' + 'main tear-off trampoline@http://localhost:8080/test.wasm:wasm-function[794]:0x14387\n' + '_invokeMain@http://localhost:8080/test.wasm:wasm-function[70]:0xa56c\n' + 'invoke@http://localhost:8080/test.mjs:48:26'); + + expect(trace.frames.length, 5); + + for (final frame in trace.frames) { + expect(frame is UnparsedFrame, false); + } + + expect(trace.frames[0].uri, Uri.parse('http://localhost:8080/test.wasm')); + expect(trace.frames[0].line, 1); + expect(trace.frames[0].column, 0xbaf8 + 1); + expect(trace.frames[0].member, 'Error._throwWithCurrentStackTrace'); + + expect(trace.frames[4].uri, Uri.parse('http://localhost:8080/test.mjs')); + expect(trace.frames[4].line, 48); + expect(trace.frames[4].column, 26); + expect(trace.frames[4].member, 'invoke'); + }); + + test('parses JSShell stack frace with Wasm frames correctly', () { + var trace = Trace.parse( + 'Error._throwWithCurrentStackTrace@/home/user/test.mjs line 29 > WebAssembly.compile:wasm-function[119]:0xbaf8\n' + 'main@/home/user/test.mjs line 29 > WebAssembly.compile:wasm-function[792]:0x14378\n' + 'main tear-off trampoline@/home/user/test.mjs line 29 > WebAssembly.compile:wasm-function[794]:0x14387\n' + '_invokeMain@/home/user/test.mjs line 29 > WebAssembly.compile:wasm-function[70]:0xa56c\n' + 'invokeMain@/home/user/test.mjs:361:37\n' + 'main@/home/user/run_wasm.js:416:21\n' + 'async*action@/home/user/run_wasm.js:353:44\n' + 'eventLoop@/home/user/run_wasm.js:329:15\n' + 'self.dartMainRunner@/home/user/run_wasm.js:354:14\n' + '@/home/user/run_wasm.js:419:15'); + + expect(trace.frames.length, 10); + + for (final frame in trace.frames) { + expect(frame is UnparsedFrame, false); + } + + expect(trace.frames[0].uri, Uri.parse('file:///home/user/test.mjs')); + expect(trace.frames[0].line, 1); + expect(trace.frames[0].column, 0xbaf8 + 1); + expect(trace.frames[0].member, 'Error._throwWithCurrentStackTrace'); + + expect(trace.frames[4].uri, Uri.parse('file:///home/user/test.mjs')); + expect(trace.frames[4].line, 361); + expect(trace.frames[4].column, 37); + expect(trace.frames[4].member, 'invokeMain'); + + expect(trace.frames[9].uri, Uri.parse('file:///home/user/run_wasm.js')); + expect(trace.frames[9].line, 419); + expect(trace.frames[9].column, 15); + expect(trace.frames[9].member, ''); + }); + + test('parses Safari stack frace with Wasm frames correctly', () { + var trace = Trace.parse( + '.wasm-function[Error._throwWithCurrentStackTrace]@[wasm code]\n' + '.wasm-function[main]@[wasm code]\n' + '.wasm-function[main tear-off trampoline]@[wasm code]\n' + '.wasm-function[_invokeMain]@[wasm code]\n' + 'invokeMain@/home/user/test.mjs:361:48\n' + '@/home/user/run_wasm.js:416:31'); + + expect(trace.frames.length, 6); + + for (final frame in trace.frames) { + expect(frame is UnparsedFrame, false); + } + + expect(trace.frames[0].uri, Uri.parse('wasm code')); + expect(trace.frames[0].line, null); + expect(trace.frames[0].column, null); + expect(trace.frames[0].member, 'Error._throwWithCurrentStackTrace'); + + expect(trace.frames[4].uri, Uri.parse('file:///home/user/test.mjs')); + expect(trace.frames[4].line, 361); + expect(trace.frames[4].column, 48); + expect(trace.frames[4].member, 'invokeMain'); + + expect(trace.frames[5].uri, Uri.parse('file:///home/user/run_wasm.js')); + expect(trace.frames[5].line, 416); + expect(trace.frames[5].column, 31); + expect(trace.frames[5].member, ''); + }); + }); + + test('.toString() nicely formats the stack trace', () { + var trace = Trace.parse(''' +#0 Foo._bar (foo/bar.dart:42:21) +#1 zip..zap (dart:async/future.dart:0:2) +#2 zip..zap (https://pub.dev/thing.dart:1:100) +'''); + + expect(trace.toString(), equals(''' +${path.join('foo', 'bar.dart')} 42:21 Foo._bar +dart:async/future.dart 0:2 zip..zap +https://pub.dev/thing.dart 1:100 zip..zap +''')); + }); + + test('.vmTrace returns a native-style trace', () { + var uri = path.toUri(path.absolute('foo')); + var trace = Trace([ + Frame(uri, 10, 20, 'Foo.'), + Frame(Uri.parse('https://dart.dev/foo.dart'), null, null, 'bar'), + Frame(Uri.parse('dart:async'), 15, null, 'baz'), + ]); + + expect( + trace.vmTrace.toString(), + equals('#1 Foo. ($uri:10:20)\n' + '#2 bar (https://dart.dev/foo.dart:0:0)\n' + '#3 baz (dart:async:15:0)\n')); + }); + + group('folding', () { + group('.terse', () { + test('folds core frames together bottom-up', () { + var trace = Trace.parse(''' +#1 top (dart:async/future.dart:0:2) +#2 bottom (dart:core/uri.dart:1:100) +#0 notCore (foo.dart:42:21) +#3 top (dart:io:5:10) +#4 bottom (dart:async-patch/future.dart:9:11) +#5 alsoNotCore (bar.dart:10:20) +'''); + + expect(trace.terse.toString(), equals(''' +dart:core bottom +foo.dart 42:21 notCore +dart:async bottom +bar.dart 10:20 alsoNotCore +''')); + }); + + test('folds empty async frames', () { + var trace = Trace.parse(''' +#0 top (dart:async/future.dart:0:2) +#1 empty.<_async_body> (bar.dart) +#2 bottom (dart:async-patch/future.dart:9:11) +#3 notCore (foo.dart:42:21) +'''); + + expect(trace.terse.toString(), equals(''' +dart:async bottom +foo.dart 42:21 notCore +''')); + }); + + test('removes the bottom-most async frame', () { + var trace = Trace.parse(''' +#0 notCore (foo.dart:42:21) +#1 top (dart:async/future.dart:0:2) +#2 bottom (dart:core/uri.dart:1:100) +#3 top (dart:io:5:10) +#4 bottom (dart:async-patch/future.dart:9:11) +'''); + + expect(trace.terse.toString(), equals(''' +foo.dart 42:21 notCore +''')); + }); + + test("won't make a trace empty", () { + var trace = Trace.parse(''' +#1 top (dart:async/future.dart:0:2) +#2 bottom (dart:core/uri.dart:1:100) +'''); + + expect(trace.terse.toString(), equals(''' +dart:core bottom +''')); + }); + + test("won't panic on an empty trace", () { + expect(Trace.parse('').terse.toString(), equals('')); + }); + }); + + group('.foldFrames', () { + test('folds frames together bottom-up', () { + var trace = Trace.parse(''' +#0 notFoo (foo.dart:42:21) +#1 fooTop (bar.dart:0:2) +#2 fooBottom (foo.dart:1:100) +#3 alsoNotFoo (bar.dart:10:20) +#4 fooTop (dart:io/socket.dart:5:10) +#5 fooBottom (dart:async-patch/future.dart:9:11) +'''); + + var folded = + trace.foldFrames((frame) => frame.member!.startsWith('foo')); + expect(folded.toString(), equals(''' +foo.dart 42:21 notFoo +foo.dart 1:100 fooBottom +bar.dart 10:20 alsoNotFoo +dart:async-patch/future.dart 9:11 fooBottom +''')); + }); + + test('will never fold unparsed frames', () { + var trace = Trace.parse(r''' +.g"cs$#:b";a#>sw{*{ul$"$xqwr`p +%+j-?uppx<([j@#nu{{>*+$%x-={`{ +!e($b{nj)zs?cgr%!;bmw.+$j+pfj~ +'''); + + expect(trace.foldFrames((frame) => true).toString(), equals(r''' +.g"cs$#:b";a#>sw{*{ul$"$xqwr`p +%+j-?uppx<([j@#nu{{>*+$%x-={`{ +!e($b{nj)zs?cgr%!;bmw.+$j+pfj~ +''')); + }); + + group('with terse: true', () { + test('folds core frames as well', () { + var trace = Trace.parse(''' +#0 notFoo (foo.dart:42:21) +#1 fooTop (bar.dart:0:2) +#2 coreBottom (dart:async/future.dart:0:2) +#3 alsoNotFoo (bar.dart:10:20) +#4 fooTop (foo.dart:9:11) +#5 coreBottom (dart:async-patch/future.dart:9:11) +'''); + + var folded = trace.foldFrames( + (frame) => frame.member!.startsWith('foo'), + terse: true); + expect(folded.toString(), equals(''' +foo.dart 42:21 notFoo +dart:async coreBottom +bar.dart 10:20 alsoNotFoo +''')); + }); + + test('shortens folded frames', () { + var trace = Trace.parse(''' +#0 notFoo (foo.dart:42:21) +#1 fooTop (bar.dart:0:2) +#2 fooBottom (package:foo/bar.dart:0:2) +#3 alsoNotFoo (bar.dart:10:20) +#4 fooTop (foo.dart:9:11) +#5 fooBottom (foo/bar.dart:9:11) +#6 againNotFoo (bar.dart:20:20) +'''); + + var folded = trace.foldFrames( + (frame) => frame.member!.startsWith('foo'), + terse: true); + expect(folded.toString(), equals(''' +foo.dart 42:21 notFoo +package:foo fooBottom +bar.dart 10:20 alsoNotFoo +foo fooBottom +bar.dart 20:20 againNotFoo +''')); + }); + + test('removes the bottom-most folded frame', () { + var trace = Trace.parse(''' +#2 fooTop (package:foo/bar.dart:0:2) +#3 notFoo (bar.dart:10:20) +#5 fooBottom (foo/bar.dart:9:11) +'''); + + var folded = trace.foldFrames( + (frame) => frame.member!.startsWith('foo'), + terse: true); + expect(folded.toString(), equals(''' +package:foo fooTop +bar.dart 10:20 notFoo +''')); + }); + }); + }); + }); +} diff --git a/pkgs/stack_trace/test/utils.dart b/pkgs/stack_trace/test/utils.dart new file mode 100644 index 000000000..98cb5ede0 --- /dev/null +++ b/pkgs/stack_trace/test/utils.dart @@ -0,0 +1,14 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/test.dart'; + +/// Returns a matcher that runs [matcher] against a [Frame]'s `member` field. +Matcher frameMember(Object? matcher) => + isA().having((p0) => p0.member, 'member', matcher); + +/// Returns a matcher that runs [matcher] against a [Frame]'s `library` field. +Matcher frameLibrary(Object? matcher) => + isA().having((p0) => p0.library, 'library', matcher); diff --git a/pkgs/stack_trace/test/vm_test.dart b/pkgs/stack_trace/test/vm_test.dart new file mode 100644 index 000000000..70ac0143a --- /dev/null +++ b/pkgs/stack_trace/test/vm_test.dart @@ -0,0 +1,112 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// This file tests stack_trace's ability to parse live stack traces. It's a +/// dual of dartium_test.dart, since method names can differ somewhat from +/// platform to platform. No similar file exists for dart2js since the specific +/// method names there are implementation details. +@TestOn('vm') +library; + +import 'package:path/path.dart' as path; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/test.dart'; + +// The name of this (trivial) function is verified as part of the test +String getStackTraceString() => StackTrace.current.toString(); + +// The name of this (trivial) function is verified as part of the test +StackTrace getStackTraceObject() => StackTrace.current; + +Frame getCaller([int? level]) { + if (level == null) return Frame.caller(); + return Frame.caller(level); +} + +Frame nestedGetCaller(int level) => getCaller(level); + +Trace getCurrentTrace([int level = 0]) => Trace.current(level); + +Trace nestedGetCurrentTrace(int level) => getCurrentTrace(level); + +void main() { + group('Trace', () { + test('.parse parses a real stack trace correctly', () { + var string = getStackTraceString(); + var trace = Trace.parse(string); + expect(path.url.basename(trace.frames.first.uri.path), + equals('vm_test.dart')); + expect(trace.frames.first.member, equals('getStackTraceString')); + }); + + test('converts from a native stack trace correctly', () { + var trace = Trace.from(getStackTraceObject()); + expect(path.url.basename(trace.frames.first.uri.path), + equals('vm_test.dart')); + expect(trace.frames.first.member, equals('getStackTraceObject')); + }); + + test('.from handles a stack overflow trace correctly', () { + void overflow() => overflow(); + + late Trace? trace; + try { + overflow(); + } catch (_, stackTrace) { + trace = Trace.from(stackTrace); + } + + expect(trace!.frames.first.member, equals('main...overflow')); + }); + + group('.current()', () { + test('with no argument returns a trace starting at the current frame', + () { + var trace = Trace.current(); + expect(trace.frames.first.member, equals('main...')); + }); + + test('at level 0 returns a trace starting at the current frame', () { + var trace = Trace.current(); + expect(trace.frames.first.member, equals('main...')); + }); + + test('at level 1 returns a trace starting at the parent frame', () { + var trace = getCurrentTrace(1); + expect(trace.frames.first.member, equals('main...')); + }); + + test('at level 2 returns a trace starting at the grandparent frame', () { + var trace = nestedGetCurrentTrace(2); + expect(trace.frames.first.member, equals('main...')); + }); + + test('throws an ArgumentError for negative levels', () { + expect(() => Trace.current(-1), throwsArgumentError); + }); + }); + }); + + group('Frame.caller()', () { + test('with no argument returns the parent frame', () { + expect(getCaller().member, equals('main..')); + }); + + test('at level 0 returns the current frame', () { + expect(getCaller(0).member, equals('getCaller')); + }); + + test('at level 1 returns the current frame', () { + expect(getCaller(1).member, equals('main..')); + }); + + test('at level 2 returns the grandparent frame', () { + expect(nestedGetCaller(2).member, equals('main..')); + }); + + test('throws an ArgumentError for negative levels', () { + expect(() => Frame.caller(-1), throwsArgumentError); + }); + }); +} diff --git a/pkgs/stream_transform/.gitignore b/pkgs/stream_transform/.gitignore new file mode 100644 index 000000000..bfffcc6c6 --- /dev/null +++ b/pkgs/stream_transform/.gitignore @@ -0,0 +1,6 @@ +.pub/ +.dart_tool/ +build/ +packages +pubspec.lock +.packages diff --git a/pkgs/stream_transform/CHANGELOG.md b/pkgs/stream_transform/CHANGELOG.md new file mode 100644 index 000000000..a71b2fb1c --- /dev/null +++ b/pkgs/stream_transform/CHANGELOG.md @@ -0,0 +1,185 @@ +## 2.1.1 + +- Require Dart 3.1 or greater +- Forward errors from the `trigger` future through to the result stream in + `takeUntil`. Previously an error would have not closed the stream, and instead + raised as an unhandled async error. +- Move to `dart-lang/tools` monorepo. + +## 2.1.0 + +- Add `whereNotNull`. + +## 2.0.1 + +- Require Dart 2.14 or greater. +- Wait for the future returned from `StreamSubscription.cancel()` before + listening to the subsequent stream in `switchLatest` and `switchMap`. + +## 2.0.0 + +- Migrate to null safety. +- Improve tests of `switchMap` and improve documentation with links and + clarification. +- Add `trailing` argument to `throttle`. + +## 1.2.0 + +- Add support for emitting the "leading" event in `debounce`. + +## 1.1.1 + +- Fix a bug in `asyncMapSample`, `buffer`, `combineLatest`, + `combineLatestAll`, `merge`, and `mergeAll` which would cause an exception + when cancelling a subscription after using the transformer if the original + stream(s) returned `null` from cancelling their subscriptions. + +## 1.1.0 + +- Add `concurrentAsyncExpand` to interleave events emitted by multiple sub + streams created by a callback. + +## 1.0.0 + +- Remove the top level methods and retain the extensions only. + +## 0.0.20 + +- Add extension methods for most transformers. These should be used in place + of the current methods. All current implementations are deprecated and will + be removed in the next major version bump. + - Migrating typical use: Instead of + `stream.transform(debounce(Duration(seconds: 1)))` use + `stream.debounce(Duration(seconds: 1))`. + - To migrate a usage where a `StreamTransformer` instance is stored or + passed see "Getting a StreamTransformer instance" on the README. +- The `map` and `chainTransformers` utilities are no longer useful with the + new patterns so they are deprecated without a replacement. If you still have + a need for them they can be replicated with `StreamTransformer.fromBind`: + + ``` + // Replace `map(convert)` + StreamTransformer.fromBind((s) => s.map(convert)); + + // Replace `chainTransformers(first, second)` + StreamTransformer.fromBind((s) => s.transform(first).transform(second)); + ``` + +## 0.0.19 + +- Add `asyncMapSample` transform. + +## 0.0.18 + +- Internal cleanup. Passed "trigger" streams or futures now allow `` + generic type rather than an implicit `dynamic>` + +## 0.0.17 + +- Add concrete types to the `onError` callback in `tap`. + +## 0.0.16+1 + +- Remove usage of Set literal which is not available before Dart 2.2.0 + +## 0.0.16 + +- Allow a `combine` callback to return a `FutureOr` in `scan`. There are no + behavior changes for synchronous callbacks. **Potential breaking change** In + the unlikely situation where `scan` was used to produce a `Stream` + inference may now fail and require explicit generic type arguments. +- Add `combineLatest`. +- Add `combineLatestAll`. + +## 0.0.15 + +- Add `whereType`. + +## 0.0.14+1 + +- Allow using non-dev Dart 2 SDK. + +## 0.0.14 + +- `asyncWhere` will now forward exceptions thrown by the callback through the + result Stream. +- Added `concurrentAsyncMap`. + +## 0.0.13 + +- `mergeAll` now accepts an `Iterable` instead of only `List`. + +## 0.0.12 + +- Add `chainTransformers` and `map` for use cases where `StreamTransformer` + instances are stored as variables or passed to methods other than `transform`. + +## 0.0.11 + +- Renamed `concat` as `followedBy` to match the naming of `Iterable.followedBy`. + `concat` is now deprecated. + +## 0.0.10 + +- Updates to support Dart 2.0 core library changes (wave + 2.2). See [issue 31847][sdk#31847] for details. + + [sdk#31847]: https://github.com/dart-lang/sdk/issues/31847 + +## 0.0.9 + +- Add `asyncMapBuffer`. + +## 0.0.8 + +- Add `takeUntil`. + +## 0.0.7 + +- Bug Fix: Streams produced with `scan` and `switchMap` now correctly report + `isBroadcast`. +- Add `startWith`, `startWithMany`, and `startWithStream`. + +## 0.0.6 + +- Bug Fix: Some transformers did not correctly add data to all listeners on + broadcast streams. Fixed for `throttle`, `debounce`, `asyncWhere` and `audit`. +- Bug Fix: Only call the `tap` data callback once per event rather than once per + listener. +- Bug Fix: Allow canceling and re-listening to broadcast streams after a + `merge` transform. +- Bug Fix: Broadcast streams which are buffered using a single-subscription + trigger can be canceled and re-listened. +- Bug Fix: Buffer outputs one more value if there is a pending trigger before + the trigger closes. +- Bug Fix: Single-subscription streams concatted after broadcast streams are + handled correctly. +- Use sync `StreamControllers` for forwarding where possible. + +## 0.0.5 + +- Bug Fix: Allow compiling switchLatest with Dart2Js. +- Add `asyncWhere`: Like `where` but allows an asynchronous predicate. + +## 0.0.4 +- Add `scan`: fold which returns intermediate values +- Add `throttle`: block events for a duration after emitting a value +- Add `audit`: emits the last event received after a duration + +## 0.0.3 + +- Add `tap`: React to values as they pass without being a subscriber on a stream +- Add `switchMap` and `switchLatest`: Flatten a Stream of Streams into a Stream + which forwards values from the most recent Stream + +## 0.0.2 + +- Add `concat`: Appends streams in series +- Add `merge` and `mergeAll`: Interleaves streams + +## 0.0.1 + +- Initial release with the following utilities: + - `buffer`: Collects events in a `List` until a `trigger` stream fires. + - `debounce`, `debounceBuffer`: Collect or drop events which occur closer in + time than a given duration. diff --git a/pkgs/stream_transform/LICENSE b/pkgs/stream_transform/LICENSE new file mode 100644 index 000000000..03af64abe --- /dev/null +++ b/pkgs/stream_transform/LICENSE @@ -0,0 +1,27 @@ +Copyright 2017, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/stream_transform/README.md b/pkgs/stream_transform/README.md new file mode 100644 index 000000000..e7049bdc5 --- /dev/null +++ b/pkgs/stream_transform/README.md @@ -0,0 +1,141 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/stream_transform.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/stream_transform.yaml) +[![pub package](https://img.shields.io/pub/v/stream_transform.svg)](https://pub.dev/packages/stream_transform) +[![package publisher](https://img.shields.io/pub/publisher/stream_transform.svg)](https://pub.dev/packages/stream_transform/publisher) + +Extension methods on `Stream` adding common transform operators. + +## Operators + +### asyncMapBuffer, asyncMapSample, concurrentAsyncMap + +Alternatives to `asyncMap`. `asyncMapBuffer` prevents the callback from +overlapping execution and collects events while it is executing. +`asyncMapSample` prevents overlapping execution and discards events while it is +executing. `concurrentAsyncMap` allows overlap and removes ordering guarantees +for higher throughput. + +Like `asyncMap` but events are buffered in a List until previous events have +been processed rather than being called for each element individually. + +### asyncWhere + +Like `where` but allows an asynchronous predicate. + +### audit + +Waits for a period of time after receiving a value and then only emits the most +recent value. + +### buffer + +Collects values from a source stream until a `trigger` stream fires and the +collected values are emitted. + +### combineLatest, combineLatestAll + +Combine the most recent event from multiple streams through a callback or into a +list. + +### debounce, debounceBuffer + +Prevents a source stream from emitting too frequently by dropping or collecting +values that occur within a given duration. + +### followedBy + +Appends the values of a stream after another stream finishes. + +### merge, mergeAll, concurrentAsyncExpand + +Interleaves events from multiple streams into a single stream. + +### scan + +Scan is like fold, but instead of producing a single value it yields each +intermediate accumulation. + +### startWith, startWithMany, startWithStream + +Prepend a value, an iterable, or a stream to the beginning of another stream. + +### switchMap, switchLatest + +Flatten a Stream of Streams into a Stream which forwards values from the most +recent Stream + +### takeUntil + +Let values through until a Future fires. + +### tap + +Taps into a single-subscriber stream to react to values as they pass, without +being a real subscriber. + +### throttle + +Blocks events for a duration after an event is successfully emitted. + +### whereType + +Like `Iterable.whereType` for a stream. + +## Comparison to Rx Operators + +The semantics and naming in this package have some overlap, and some conflict, +with the [ReactiveX](https://reactivex.io/) suite of libraries. Some of the +conflict is intentional - Dart `Stream` predates `Observable` and coherence with +the Dart ecosystem semantics and naming is a strictly higher priority than +consistency with ReactiveX. + +Rx Operator Category | variation | `stream_transform` +------------------------- | ------------------------------------------------------ | ------------------ +[`sample`][rx_sample] | `sample/throttleLast(Duration)` | `sample(Stream.periodic(Duration), longPoll: false)` +​ | `throttleFirst(Duration)` | [`throttle`][throttle] +​ | `sample(Observable)` | `sample(trigger, longPoll: false)` +[`debounce`][rx_debounce] | `debounce/throttleWithTimeout(Duration)` | [`debounce`][debounce] +​ | `debounce(Observable)` | No equivalent +[`buffer`][rx_buffer] | `buffer(boundary)`, `bufferWithTime`,`bufferWithCount` | No equivalent +​ | `buffer(boundaryClosingSelector)` | `buffer(trigger, longPoll: false)` +RxJs extensions | [`audit(callback)`][rxjs_audit] | No equivalent +​ | [`auditTime(Duration)`][rxjs_auditTime] | [`audit`][audit] +​ | [`exhaustMap`][rxjs_exhaustMap] | No equivalent +​ | [`throttleTime(trailing: true)`][rxjs_throttleTime] | `throttle(trailing: true)` +​ | `throttleTime(leading: false, trailing: true)` | No equivalent +No equivalent? | | [`asyncMapBuffer`][asyncMapBuffer] +​ | | [`asyncMapSample`][asyncMapSample] +​ | | [`buffer`][buffer] +​ | | [`sample`][sample] +​ | | [`debounceBuffer`][debounceBuffer] +​ | | `debounce(leading: true, trailing: false)` +​ | | `debounce(leading: true, trailing: true)` + +[rx_sample]:https://reactivex.io/documentation/operators/sample.html +[rx_debounce]:https://reactivex.io/documentation/operators/debounce.html +[rx_buffer]:https://reactivex.io/documentation/operators/buffer.html +[rxjs_audit]:https://rxjs.dev/api/operators/audit +[rxjs_auditTime]:https://rxjs.dev/api/operators/auditTime +[rxjs_throttleTime]:https://rxjs.dev/api/operators/throttleTime +[rxjs_exhaustMap]:https://rxjs.dev/api/operators/exhaustMap +[asyncMapBuffer]:https://pub.dev/documentation/stream_transform/latest/stream_transform/AsyncMap/asyncMapBuffer.html +[asyncMapSample]:https://pub.dev/documentation/stream_transform/latest/stream_transform/AsyncMap/asyncMapSample.html +[audit]:https://pub.dev/documentation/stream_transform/latest/stream_transform/RateLimit/audit.html +[buffer]:https://pub.dev/documentation/stream_transform/latest/stream_transform/RateLimit/buffer.html +[sample]:https://pub.dev/documentation/stream_transform/latest/stream_transform/RateLimit/sample.html +[debounceBuffer]:https://pub.dev/documentation/stream_transform/latest/stream_transform/RateLimit/debounceBuffer.html +[debounce]:https://pub.dev/documentation/stream_transform/latest/stream_transform/RateLimit/debounce.html +[throttle]:https://pub.dev/documentation/stream_transform/latest/stream_transform/RateLimit/throttle.html + +## Getting a `StreamTransformer` instance + +It may be useful to pass an instance of `StreamTransformer` so that it can be +used with `stream.transform` calls rather than reference the specific operator +in place. Any operator on `Stream` that returns a `Stream` can be modeled as a +`StreamTransformer` using the [`fromBind` constructor][fromBind]. + +```dart +final debounce = StreamTransformer.fromBind( + (s) => s.debounce(const Duration(milliseconds: 100))); +``` + +[fromBind]: https://api.dart.dev/stable/dart-async/StreamTransformer/StreamTransformer.fromBind.html diff --git a/pkgs/stream_transform/analysis_options.yaml b/pkgs/stream_transform/analysis_options.yaml new file mode 100644 index 000000000..05f1af1ae --- /dev/null +++ b/pkgs/stream_transform/analysis_options.yaml @@ -0,0 +1,16 @@ +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-raw-types: true + +linter: + rules: + - avoid_bool_literals_in_conditional_expressions + - avoid_classes_with_only_static_members + - avoid_returning_this + - avoid_unused_constructor_parameters + - cascade_invocations + - join_return_with_assignment + - no_adjacent_strings_in_list diff --git a/pkgs/stream_transform/example/index.html b/pkgs/stream_transform/example/index.html new file mode 100644 index 000000000..aecdc0957 --- /dev/null +++ b/pkgs/stream_transform/example/index.html @@ -0,0 +1,11 @@ + + + + + +
+
+

+

+ + diff --git a/pkgs/stream_transform/example/main.dart b/pkgs/stream_transform/example/main.dart new file mode 100644 index 000000000..70b3e7f28 --- /dev/null +++ b/pkgs/stream_transform/example/main.dart @@ -0,0 +1,26 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:html'; + +import 'package:stream_transform/stream_transform.dart'; + +void main() { + var firstInput = document.querySelector('#first_input') as InputElement; + var secondInput = document.querySelector('#second_input') as InputElement; + var output = document.querySelector('#output')!; + + _inputValues(firstInput) + .combineLatest(_inputValues(secondInput), + (first, second) => 'First: $first, Second: $second') + .tap((v) { + print('Saw: $v'); + }).forEach((v) { + output.text = v; + }); +} + +Stream _inputValues(InputElement element) => element.onKeyUp + .debounce(const Duration(milliseconds: 100)) + .map((_) => element.value); diff --git a/pkgs/stream_transform/lib/src/aggregate_sample.dart b/pkgs/stream_transform/lib/src/aggregate_sample.dart new file mode 100644 index 000000000..f2ff8ed3b --- /dev/null +++ b/pkgs/stream_transform/lib/src/aggregate_sample.dart @@ -0,0 +1,146 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'common_callbacks.dart'; + +extension AggregateSample on Stream { + /// Computes a value based on sequences of events, then emits that value when + /// [trigger] emits an event. + /// + /// Every time this stream emits an event, an intermediate value is created + /// by combining the new event with the previous intermediate value, or with + /// `null` if there is no previous value, using the [aggregate] function. + /// + /// When [trigger] emits value, the returned stream emits the current + /// intermediate value and clears it. + /// + /// If [longPoll] is `false`, if there is no intermediate value when [trigger] + /// emits an event, the [onEmpty] function is called with a [Sink] which can + /// add events to the returned stream. + /// + /// If [longPoll] is `true`, and there is no intermediate value when [trigger] + /// emits one or more events, then the *next* event from this stream is + /// immediately put through [aggregate] and emitted on the returned stream. + /// Subsequent events on [trigger] while there have been no events on this + /// stream are ignored. + /// In that case, [onEmpty] is never used. + /// + /// The result stream will close as soon as there is a guarantee it will not + /// emit any more events. There will not be any more events emitted if: + /// - [trigger] is closed and there is no waiting long poll. + /// - Or, the source stream is closed and there are no buffered events. + /// + /// If the source stream is a broadcast stream, the result will be as well. + /// Errors from the source stream or the trigger are immediately forwarded to + /// the output. + Stream aggregateSample( + {required Stream trigger, + required S Function(T, S?) aggregate, + required bool longPoll, + required void Function(Sink) onEmpty}) { + var controller = isBroadcast + ? StreamController.broadcast(sync: true) + : StreamController(sync: true); + + S? currentResults; + var hasCurrentResults = false; + var activeLongPoll = false; + var isTriggerDone = false; + var isValueDone = false; + StreamSubscription? valueSub; + StreamSubscription? triggerSub; + + void emit(S results) { + currentResults = null; + hasCurrentResults = false; + controller.add(results); + } + + void onValue(T value) { + currentResults = aggregate(value, currentResults); + hasCurrentResults = true; + if (!longPoll) return; + + if (activeLongPoll) { + activeLongPoll = false; + emit(currentResults as S); + } + + if (isTriggerDone) { + valueSub!.cancel(); + controller.close(); + } + } + + void onValuesDone() { + isValueDone = true; + if (!hasCurrentResults) { + triggerSub?.cancel(); + controller.close(); + } + } + + void onTrigger(_) { + if (hasCurrentResults) { + emit(currentResults as S); + } else if (longPoll) { + activeLongPoll = true; + } else { + onEmpty(controller); + } + + if (isValueDone) { + triggerSub!.cancel(); + controller.close(); + } + } + + void onTriggerDone() { + isTriggerDone = true; + if (!activeLongPoll) { + valueSub?.cancel(); + controller.close(); + } + } + + controller.onListen = () { + assert(valueSub == null); + valueSub = + listen(onValue, onError: controller.addError, onDone: onValuesDone); + final priorTriggerSub = triggerSub; + if (priorTriggerSub != null) { + if (priorTriggerSub.isPaused) priorTriggerSub.resume(); + } else { + triggerSub = trigger.listen(onTrigger, + onError: controller.addError, onDone: onTriggerDone); + } + if (!isBroadcast) { + controller + ..onPause = () { + valueSub?.pause(); + triggerSub?.pause(); + } + ..onResume = () { + valueSub?.resume(); + triggerSub?.resume(); + }; + } + controller.onCancel = () { + var cancels = >[if (!isValueDone) valueSub!.cancel()]; + valueSub = null; + if (trigger.isBroadcast || !isBroadcast) { + if (!isTriggerDone) cancels.add(triggerSub!.cancel()); + triggerSub = null; + } else { + triggerSub!.pause(); + } + if (cancels.isEmpty) return null; + return cancels.wait.then(ignoreArgument); + }; + }; + return controller.stream; + } +} diff --git a/pkgs/stream_transform/lib/src/async_expand.dart b/pkgs/stream_transform/lib/src/async_expand.dart new file mode 100644 index 000000000..28d2f4076 --- /dev/null +++ b/pkgs/stream_transform/lib/src/async_expand.dart @@ -0,0 +1,89 @@ +// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'common_callbacks.dart'; +import 'switch.dart'; + +/// Alternatives to [asyncExpand]. +/// +/// The built in [asyncExpand] will not overlap the inner streams and every +/// event will be sent to the callback individually. +/// +/// - [concurrentAsyncExpand] allow overlap and merges inner streams without +/// ordering guarantees. +extension AsyncExpand on Stream { + /// Like [asyncExpand] but the [convert] callback may be called for an element + /// before the [Stream] emitted by the previous element has closed. + /// + /// Events on the result stream will be emitted in the order they are emitted + /// by the sub streams, which may not match the order of this stream. + /// + /// Errors from [convert], the source stream, or any of the sub streams are + /// forwarded to the result stream. + /// + /// The result stream will not close until the source stream closes and all + /// sub streams have closed. + /// + /// If the source stream is a broadcast stream, the result will be as well, + /// regardless of the types of streams created by [convert]. In this case, + /// some care should be taken: + /// - If [convert] returns a single subscription stream it may be listened to + /// and never canceled. + /// - For any period of time where there are no listeners on the result + /// stream, any sub streams from previously emitted events will be ignored, + /// regardless of whether they emit further events after a listener is added + /// back. + /// + /// See also: + /// - [switchMap], which cancels subscriptions to the previous sub stream + /// instead of concurrently emitting events from all sub streams. + Stream concurrentAsyncExpand(Stream Function(T) convert) { + final controller = isBroadcast + ? StreamController.broadcast(sync: true) + : StreamController(sync: true); + + controller.onListen = () { + final subscriptions = >[]; + final outerSubscription = map(convert).listen((inner) { + if (isBroadcast && !inner.isBroadcast) { + inner = inner.asBroadcastStream(); + } + final subscription = + inner.listen(controller.add, onError: controller.addError); + subscription.onDone(() { + subscriptions.remove(subscription); + if (subscriptions.isEmpty) controller.close(); + }); + subscriptions.add(subscription); + }, onError: controller.addError); + outerSubscription.onDone(() { + subscriptions.remove(outerSubscription); + if (subscriptions.isEmpty) controller.close(); + }); + subscriptions.add(outerSubscription); + if (!isBroadcast) { + controller + ..onPause = () { + for (final subscription in subscriptions) { + subscription.pause(); + } + } + ..onResume = () { + for (final subscription in subscriptions) { + subscription.resume(); + } + }; + } + controller.onCancel = () { + if (subscriptions.isEmpty) return null; + return [for (var s in subscriptions) s.cancel()] + .wait + .then(ignoreArgument); + }; + }; + return controller.stream; + } +} diff --git a/pkgs/stream_transform/lib/src/async_map.dart b/pkgs/stream_transform/lib/src/async_map.dart new file mode 100644 index 000000000..094df9c94 --- /dev/null +++ b/pkgs/stream_transform/lib/src/async_map.dart @@ -0,0 +1,136 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'aggregate_sample.dart'; +import 'common_callbacks.dart'; +import 'from_handlers.dart'; +import 'rate_limit.dart'; + +/// Alternatives to [asyncMap]. +/// +/// The built in [asyncMap] will not overlap execution of the passed callback, +/// and every event will be sent to the callback individually. +/// +/// - [asyncMapBuffer] prevents the callback from overlapping execution and +/// collects events while it is executing to process in batches. +/// - [asyncMapSample] prevents overlapping execution and discards events while +/// it is executing. +/// - [concurrentAsyncMap] allows overlap and removes ordering guarantees. +extension AsyncMap on Stream { + /// Like [asyncMap] but events are buffered until previous events have been + /// processed by [convert]. + /// + /// If this stream is a broadcast stream the result will be as well. + /// When used with a broadcast stream behavior also differs from [asyncMap] in + /// that the [convert] function is only called once per event, rather than + /// once per listener per event. + /// + /// The first event from this stream is always passed to [convert] as a + /// list with a single element. + /// After that, events are buffered until the previous Future returned from + /// [convert] has completed. + /// + /// Errors from this stream are forwarded directly to the result stream. + /// Errors during the conversion are also forwarded to the result stream and + /// are considered completing work so the next values are let through. + /// + /// The result stream will not close until this stream closes and all pending + /// conversions have finished. + Stream asyncMapBuffer(Future Function(List) convert) { + var workFinished = StreamController() + // Let the first event through. + ..add(null); + return buffer(workFinished.stream)._asyncMapThen(convert, workFinished.add); + } + + /// Like [asyncMap] but events are discarded while work is happening in + /// [convert]. + /// + /// If this stream is a broadcast stream the result will be as well. + /// When used with a broadcast stream behavior also differs from [asyncMap] in + /// that the [convert] function is only called once per event, rather than + /// once per listener per event. + /// + /// If no work is happening when an event is emitted it will be immediately + /// passed to [convert]. If there is ongoing work when an event is emitted it + /// will be held until the work is finished. New events emitted will replace a + /// pending event. + /// + /// Errors from this stream are forwarded directly to the result stream. + /// Errors during the conversion are also forwarded to the result stream and + /// are considered completing work so the next values are let through. + /// + /// The result stream will not close until this stream closes and all pending + /// conversions have finished. + Stream asyncMapSample(Future Function(T) convert) { + var workFinished = StreamController() + // Let the first event through. + ..add(null); + return aggregateSample( + trigger: workFinished.stream, + aggregate: _dropPrevious, + longPoll: true, + onEmpty: ignoreArgument) + ._asyncMapThen(convert, workFinished.add); + } + + /// Like [asyncMap] but the [convert] callback may be called for an element + /// before processing for the previous element is finished. + /// + /// Events on the result stream will be emitted in the order that [convert] + /// completed which may not match the order of this stream. + /// + /// If this stream is a broadcast stream the result will be as well. + /// When used with a broadcast stream behavior also differs from [asyncMap] in + /// that the [convert] function is only called once per event, rather than + /// once per listener per event. The [convert] callback won't be called for + /// events while a broadcast stream has no listener. + /// + /// Errors from [convert] or this stream are forwarded directly to the + /// result stream. + /// + /// The result stream will not close until this stream closes and all pending + /// conversions have finished. + Stream concurrentAsyncMap(FutureOr Function(T) convert) { + var valuesWaiting = 0; + var sourceDone = false; + return transformByHandlers(onData: (element, sink) { + valuesWaiting++; + () async { + try { + sink.add(await convert(element)); + } catch (e, st) { + sink.addError(e, st); + } + valuesWaiting--; + if (valuesWaiting <= 0 && sourceDone) sink.close(); + }(); + }, onDone: (sink) { + sourceDone = true; + if (valuesWaiting <= 0) sink.close(); + }); + } + + /// Like [Stream.asyncMap] but the [convert] is only called once per event, + /// rather than once per listener, and [then] is called after completing the + /// work. + Stream _asyncMapThen( + Future Function(T) convert, void Function(void) then) { + Future? pendingEvent; + return transformByHandlers(onData: (event, sink) { + pendingEvent = + convert(event).then(sink.add).catchError(sink.addError).then(then); + }, onDone: (sink) { + if (pendingEvent != null) { + pendingEvent!.then((_) => sink.close()); + } else { + sink.close(); + } + }); + } +} + +T _dropPrevious(T event, _) => event; diff --git a/pkgs/stream_transform/lib/src/combine_latest.dart b/pkgs/stream_transform/lib/src/combine_latest.dart new file mode 100644 index 000000000..f02a19e55 --- /dev/null +++ b/pkgs/stream_transform/lib/src/combine_latest.dart @@ -0,0 +1,240 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'common_callbacks.dart'; + +/// Utilities to combine events from multiple streams through a callback or into +/// a list. +extension CombineLatest on Stream { + /// Combines the latest values from this stream with the latest values from + /// [other] using [combine]. + /// + /// No event will be emitted until both the source stream and [other] have + /// each emitted at least one event. If either the source stream or [other] + /// emit multiple events before the other emits the first event, all but the + /// last value will be discarded. Once both streams have emitted at least + /// once, the result stream will emit any time either input stream emits. + /// + /// The result stream will not close until both the source stream and [other] + /// have closed. + /// + /// For example: + /// + /// source.combineLatest(other, (a, b) => a + b); + /// + /// source: --1--2--------4--| + /// other: -------3--| + /// result: -------5------7--| + /// + /// Errors thrown by [combine], along with any errors on the source stream or + /// [other], are forwarded to the result stream. + /// + /// If the source stream is a broadcast stream, the result stream will be as + /// well, regardless of [other]'s type. If a single subscription stream is + /// combined with a broadcast stream it may never be canceled. + Stream combineLatest( + Stream other, FutureOr Function(T, T2) combine) { + final controller = isBroadcast + ? StreamController.broadcast(sync: true) + : StreamController(sync: true); + + other = + (isBroadcast && !other.isBroadcast) ? other.asBroadcastStream() : other; + + StreamSubscription? sourceSubscription; + StreamSubscription? otherSubscription; + + var sourceDone = false; + var otherDone = false; + + late T latestSource; + late T2 latestOther; + + var sourceStarted = false; + var otherStarted = false; + + void emitCombined() { + if (!sourceStarted || !otherStarted) return; + FutureOr result; + try { + result = combine(latestSource, latestOther); + } catch (e, s) { + controller.addError(e, s); + return; + } + if (result is Future) { + sourceSubscription!.pause(); + otherSubscription!.pause(); + result + .then(controller.add, onError: controller.addError) + .whenComplete(() { + sourceSubscription!.resume(); + otherSubscription!.resume(); + }); + } else { + controller.add(result); + } + } + + controller.onListen = () { + assert(sourceSubscription == null); + sourceSubscription = listen( + (s) { + sourceStarted = true; + latestSource = s; + emitCombined(); + }, + onError: controller.addError, + onDone: () { + sourceDone = true; + if (otherDone) { + controller.close(); + } else if (!sourceStarted) { + // Nothing can ever be emitted + otherSubscription!.cancel(); + controller.close(); + } + }); + otherSubscription = other.listen( + (o) { + otherStarted = true; + latestOther = o; + emitCombined(); + }, + onError: controller.addError, + onDone: () { + otherDone = true; + if (sourceDone) { + controller.close(); + } else if (!otherStarted) { + // Nothing can ever be emitted + sourceSubscription!.cancel(); + controller.close(); + } + }); + if (!isBroadcast) { + controller + ..onPause = () { + sourceSubscription!.pause(); + otherSubscription!.pause(); + } + ..onResume = () { + sourceSubscription!.resume(); + otherSubscription!.resume(); + }; + } + controller.onCancel = () { + var cancels = [ + sourceSubscription!.cancel(), + otherSubscription!.cancel() + ]; + sourceSubscription = null; + otherSubscription = null; + return cancels.wait.then(ignoreArgument); + }; + }; + return controller.stream; + } + + /// Combine the latest value emitted from the source stream with the latest + /// values emitted from [others]. + /// + /// [combineLatestAll] subscribes to the source stream and [others] and when + /// any one of the streams emits, the result stream will emit a [List] of + /// the latest values emitted from all streams. + /// + /// No event will be emitted until all source streams emit at least once. If a + /// source stream emits multiple values before another starts emitting, all + /// but the last value will be discarded. Once all source streams have emitted + /// at least once, the result stream will emit any time any source stream + /// emits. + /// + /// The result stream will not close until all source streams have closed. + /// When a source stream closes, the result stream will continue to emit the + /// last value from the closed stream when the other source streams emit until + /// the result stream has closed. If a source stream closes without emitting + /// any value, the result stream will close as well. + /// + /// For example: + /// + /// final combined = first + /// .combineLatestAll([second, third]) + /// .map((data) => data.join()); + /// + /// first: a----b------------------c--------d---| + /// second: --1---------2-----------------| + /// third: -------&----------%---| + /// combined: -------b1&--b2&---b2%---c2%------d2%-| + /// + /// Errors thrown by any source stream will be forwarded to the result stream. + /// + /// If the source stream is a broadcast stream, the result stream will be as + /// well, regardless of the types of [others]. If a single subscription stream + /// is combined with a broadcast source stream, it may never be canceled. + Stream> combineLatestAll(Iterable> others) { + final controller = isBroadcast + ? StreamController>.broadcast(sync: true) + : StreamController>(sync: true); + + final allStreams = [ + this, + for (final other in others) + !isBroadcast || other.isBroadcast ? other : other.asBroadcastStream(), + ]; + + controller.onListen = () { + final subscriptions = >[]; + + final latestData = List.filled(allStreams.length, null); + final hasEmitted = {}; + void handleData(int index, T data) { + latestData[index] = data; + hasEmitted.add(index); + if (hasEmitted.length == allStreams.length) { + controller.add(List.from(latestData)); + } + } + + var streamId = 0; + for (final stream in allStreams) { + final index = streamId; + + final subscription = stream.listen((data) => handleData(index, data), + onError: controller.addError); + subscription.onDone(() { + assert(subscriptions.contains(subscription)); + subscriptions.remove(subscription); + if (subscriptions.isEmpty || !hasEmitted.contains(index)) { + controller.close(); + } + }); + subscriptions.add(subscription); + + streamId++; + } + if (!isBroadcast) { + controller + ..onPause = () { + for (final subscription in subscriptions) { + subscription.pause(); + } + } + ..onResume = () { + for (final subscription in subscriptions) { + subscription.resume(); + } + }; + } + controller.onCancel = () { + if (subscriptions.isEmpty) return null; + return [for (var s in subscriptions) s.cancel()] + .wait + .then(ignoreArgument); + }; + }; + return controller.stream; + } +} diff --git a/pkgs/stream_transform/lib/src/common_callbacks.dart b/pkgs/stream_transform/lib/src/common_callbacks.dart new file mode 100644 index 000000000..c239220c2 --- /dev/null +++ b/pkgs/stream_transform/lib/src/common_callbacks.dart @@ -0,0 +1,5 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +void ignoreArgument(_) {} diff --git a/pkgs/stream_transform/lib/src/concatenate.dart b/pkgs/stream_transform/lib/src/concatenate.dart new file mode 100644 index 000000000..0330dd761 --- /dev/null +++ b/pkgs/stream_transform/lib/src/concatenate.dart @@ -0,0 +1,112 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +/// Utilities to append or prepend to a stream. +extension Concatenate on Stream { + /// Emits all values and errors from [next] following all values and errors + /// from this stream. + /// + /// If this stream never finishes, the [next] stream will never get a + /// listener. + /// + /// If this stream is a broadcast stream, the result will be as well. + /// If a single-subscription follows a broadcast stream it may be listened + /// to and never canceled since there may be broadcast listeners added later. + /// + /// If a broadcast stream follows any other stream it will miss any events or + /// errors which occur before this stream is done. + /// If a broadcast stream follows a single-subscription stream, pausing the + /// stream while it is listening to the second stream will cause events to be + /// dropped rather than buffered. + Stream followedBy(Stream next) { + var controller = isBroadcast + ? StreamController.broadcast(sync: true) + : StreamController(sync: true); + + next = isBroadcast && !next.isBroadcast ? next.asBroadcastStream() : next; + + StreamSubscription? subscription; + var currentStream = this; + var thisDone = false; + var secondDone = false; + + late void Function() currentDoneHandler; + + void listen() { + subscription = currentStream.listen(controller.add, + onError: controller.addError, onDone: () => currentDoneHandler()); + } + + void onSecondDone() { + secondDone = true; + controller.close(); + } + + void onThisDone() { + thisDone = true; + currentStream = next; + currentDoneHandler = onSecondDone; + listen(); + } + + currentDoneHandler = onThisDone; + + controller.onListen = () { + assert(subscription == null); + listen(); + if (!isBroadcast) { + controller + ..onPause = () { + if (!thisDone || !next.isBroadcast) return subscription!.pause(); + subscription!.cancel(); + subscription = null; + } + ..onResume = () { + if (!thisDone || !next.isBroadcast) return subscription!.resume(); + listen(); + }; + } + controller.onCancel = () { + if (secondDone) return null; + var toCancel = subscription!; + subscription = null; + return toCancel.cancel(); + }; + }; + return controller.stream; + } + + /// Emits [initial] before any values or errors from the this stream. + /// + /// If this stream is a broadcast stream the result will be as well. + /// If this stream is a broadcast stream, the returned stream will only + /// contain events of this stream that are emitted after the [initial] value + /// has been emitted on the returned stream. + Stream startWith(T initial) => + startWithStream(Future.value(initial).asStream()); + + /// Emits all values in [initial] before any values or errors from this + /// stream. + /// + /// If this stream is a broadcast stream the result will be as well. + /// If this stream is a broadcast stream it will miss any events which + /// occur before the initial values are all emitted. + Stream startWithMany(Iterable initial) => + startWithStream(Stream.fromIterable(initial)); + + /// Emits all values and errors in [initial] before any values or errors from + /// this stream. + /// + /// If this stream is a broadcast stream the result will be as well. + /// If this stream is a broadcast stream it will miss any events which occur + /// before [initial] closes. + Stream startWithStream(Stream initial) { + if (isBroadcast && !initial.isBroadcast) { + initial = initial.asBroadcastStream(); + } + return initial.followedBy(this); + } +} diff --git a/pkgs/stream_transform/lib/src/from_handlers.dart b/pkgs/stream_transform/lib/src/from_handlers.dart new file mode 100644 index 000000000..1146a13fa --- /dev/null +++ b/pkgs/stream_transform/lib/src/from_handlers.dart @@ -0,0 +1,58 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +extension TransformByHandlers on Stream { + /// Transform a stream by callbacks. + /// + /// This is similar to `transform(StreamTransformer.fromHandler(...))` except + /// that the handlers are called once per event rather than called for the + /// same event for each listener on a broadcast stream. + Stream transformByHandlers( + {required void Function(S, EventSink) onData, + void Function(Object, StackTrace, EventSink)? onError, + void Function(EventSink)? onDone}) { + final handleError = onError ?? _defaultHandleError; + final handleDone = onDone ?? _defaultHandleDone; + + var controller = isBroadcast + ? StreamController.broadcast(sync: true) + : StreamController(sync: true); + + StreamSubscription? subscription; + controller.onListen = () { + assert(subscription == null); + var valuesDone = false; + subscription = listen((value) => onData(value, controller), + onError: (Object error, StackTrace stackTrace) { + handleError(error, stackTrace, controller); + }, onDone: () { + valuesDone = true; + handleDone(controller); + }); + if (!isBroadcast) { + controller + ..onPause = subscription!.pause + ..onResume = subscription!.resume; + } + controller.onCancel = () { + var toCancel = subscription; + subscription = null; + if (!valuesDone) return toCancel!.cancel(); + return null; + }; + }; + return controller.stream; + } + + static void _defaultHandleError( + Object error, StackTrace stackTrace, EventSink sink) { + sink.addError(error, stackTrace); + } + + static void _defaultHandleDone(EventSink sink) { + sink.close(); + } +} diff --git a/pkgs/stream_transform/lib/src/merge.dart b/pkgs/stream_transform/lib/src/merge.dart new file mode 100644 index 000000000..3bfe06c7e --- /dev/null +++ b/pkgs/stream_transform/lib/src/merge.dart @@ -0,0 +1,102 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'common_callbacks.dart'; + +/// Utilities to interleave events from multiple streams. +extension Merge on Stream { + /// Merges values and errors from this stream and [other] in any order as they + /// arrive. + /// + /// The result stream will not close until both this stream and [other] have + /// closed. + /// + /// For example: + /// + /// final result = source.merge(other); + /// + /// source: 1--2-----3--| + /// other: ------4-------5--| + /// result: 1--2--4--3----5--| + /// + /// If this stream is a broadcast stream, the result stream will be as + /// well, regardless of [other]'s type. If a single subscription stream is + /// merged into a broadcast stream it may never be canceled since there may be + /// broadcast listeners added later. + /// + /// If a broadcast stream is merged into a single-subscription stream any + /// events emitted by [other] before the result stream has a subscriber will + /// be discarded. + Stream merge(Stream other) => mergeAll([other]); + + /// Merges values and errors from this stream and any stream in [others] in + /// any order as they arrive. + /// + /// The result stream will not close until this stream and all streams + /// in [others] have closed. + /// + /// For example: + /// + /// final result = first.mergeAll([second, third]); + /// + /// first: 1--2--------3--| + /// second: ---------4-------5--| + /// third: ------6---------------7--| + /// result: 1--2--6--4--3----5----7--| + /// + /// If this stream is a broadcast stream, the result stream will be as + /// well, regardless the types of streams in [others]. If a single + /// subscription stream is merged into a broadcast stream it may never be + /// canceled since there may be broadcast listeners added later. + /// + /// If a broadcast stream is merged into a single-subscription stream any + /// events emitted by that stream before the result stream has a subscriber + /// will be discarded. + Stream mergeAll(Iterable> others) { + final controller = isBroadcast + ? StreamController.broadcast(sync: true) + : StreamController(sync: true); + + final allStreams = [ + this, + for (final other in others) + !isBroadcast || other.isBroadcast ? other : other.asBroadcastStream(), + ]; + + controller.onListen = () { + final subscriptions = >[]; + for (final stream in allStreams) { + final subscription = + stream.listen(controller.add, onError: controller.addError); + subscription.onDone(() { + subscriptions.remove(subscription); + if (subscriptions.isEmpty) controller.close(); + }); + subscriptions.add(subscription); + } + if (!isBroadcast) { + controller + ..onPause = () { + for (final subscription in subscriptions) { + subscription.pause(); + } + } + ..onResume = () { + for (final subscription in subscriptions) { + subscription.resume(); + } + }; + } + controller.onCancel = () { + if (subscriptions.isEmpty) return null; + return [for (var s in subscriptions) s.cancel()] + .wait + .then(ignoreArgument); + }; + }; + return controller.stream; + } +} diff --git a/pkgs/stream_transform/lib/src/rate_limit.dart b/pkgs/stream_transform/lib/src/rate_limit.dart new file mode 100644 index 000000000..299c23049 --- /dev/null +++ b/pkgs/stream_transform/lib/src/rate_limit.dart @@ -0,0 +1,356 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'aggregate_sample.dart'; +import 'common_callbacks.dart'; +import 'from_handlers.dart'; + +/// Utilities to rate limit events. +/// +/// - [debounce] - emit the the _first_ or _last_ event of a series of closely +/// spaced events. +/// - [debounceBuffer] - emit _all_ events at the _end_ of a series of closely +/// spaced events. +/// - [throttle] - emit the _first_ event at the _beginning_ of the period. +/// - [audit] - emit the _last_ event at the _end_ of the period. +/// - [buffer] - emit _all_ events on a _trigger_. +extension RateLimit on Stream { + /// Suppresses events with less inter-event spacing than [duration]. + /// + /// Events which are emitted with less than [duration] elapsed between them + /// are considered to be part of the same "series". If [leading] is `true`, + /// the first event of this series is emitted immediately. If [trailing] is + /// `true` the last event of this series is emitted with a delay of at least + /// [duration]. By default only trailing events are emitted, both arguments + /// must be specified with `leading: true, trailing: false` to emit only + /// leading events. + /// + /// If this stream is a broadcast stream, the result will be as well. + /// Errors are forwarded immediately. + /// + /// If there is a trailing event waiting during the debounce period when the + /// source stream closes the returned stream will wait to emit it following + /// the debounce period before closing. If there is no pending debounced event + /// when this stream closes the returned stream will close immediately. + /// + /// For example: + /// + /// source.debounce(Duration(seconds: 1)); + /// + /// source: 1-2-3---4---5-6-| + /// result: ------3---4-----6| + /// + /// source.debounce(Duration(seconds: 1), leading: true, trailing: false); + /// + /// source: 1-2-3---4---5-6-| + /// result: 1-------4---5---| + /// + /// source.debounce(Duration(seconds: 1), leading: true); + /// + /// source: 1-2-3---4---5-6-| + /// result: 1-----3-4---5---6| + /// + /// To collect values emitted during the debounce period see [debounceBuffer]. + Stream debounce(Duration duration, + {bool leading = false, bool trailing = true}) => + _debounceAggregate(duration, _dropPrevious, + leading: leading, trailing: trailing); + + /// Buffers values until this stream does not emit for [duration] then emits + /// the collected values. + /// + /// Values will always be delayed by at least [duration], and values which + /// come within this time will be aggregated into the same list. + /// + /// If this stream is a broadcast stream, the result will be as well. + /// Errors are forwarded immediately. + /// + /// If there are events waiting during the debounce period when this stream + /// closes the returned stream will wait to emit them following the debounce + /// period before closing. If there are no pending debounced events when this + /// stream closes the returned stream will close immediately. + /// + /// To keep only the most recent event during the debounce period see + /// [debounce]. + Stream> debounceBuffer(Duration duration) => + _debounceAggregate(duration, _collect, leading: false, trailing: true); + + /// Reduces the rate that events are emitted to at most once per [duration]. + /// + /// No events will ever be emitted within [duration] of another event on the + /// result stream. + /// If this stream is a broadcast stream, the result will be as well. + /// Errors are forwarded immediately. + /// + /// If [trailing] is `false`, source events emitted during the [duration] + /// period following a result event are discarded. + /// The result stream will not emit an event until this stream emits an event + /// following the throttled period. + /// If this stream is consistently emitting events with less than + /// [duration] between events, the time between events on the result stream + /// may still be more than [duration]. + /// The result stream will close immediately when this stream closes. + /// + /// If [trailing] is `true`, the latest source event emitted during the + /// [duration] period following an result event is held and emitted following + /// the period. + /// If this stream is consistently emitting events with less than [duration] + /// between events, the time between events on the result stream will be + /// [duration]. + /// If this stream closes the result stream will wait to emit a pending event + /// before closing. + /// + /// For example: + /// + /// source.throttle(Duration(seconds: 6)); + /// + /// source: 1-2-3---4-5-6---7-8-| + /// result: 1-------4-------7---| + /// + /// source.throttle(Duration(seconds: 6), trailing: true); + /// + /// source: 1-2-3---4-5----6--| + /// result: 1-----3-----5-----6| + /// + /// source.throttle(Duration(seconds: 6), trailing: true); + /// + /// source: 1-2-----------3| + /// result: 1-----2-------3| + /// + /// See also: + /// - [audit], which emits the most recent event at the end of the period. + /// Compared to `audit`, `throttle` will not introduce delay to forwarded + /// elements, except for the [trailing] events. + /// - [debounce], which uses inter-event spacing instead of a fixed period + /// from the first event in a window. Compared to `debouce`, `throttle` cannot + /// be starved by having events emitted continuously within [duration]. + Stream throttle(Duration duration, {bool trailing = false}) => + trailing ? _throttleTrailing(duration) : _throttle(duration); + + Stream _throttle(Duration duration) { + Timer? timer; + + return transformByHandlers(onData: (data, sink) { + if (timer == null) { + sink.add(data); + timer = Timer(duration, () { + timer = null; + }); + } + }); + } + + Stream _throttleTrailing(Duration duration) { + Timer? timer; + T? pending; + var hasPending = false; + var isDone = false; + + return transformByHandlers(onData: (data, sink) { + void onTimer() { + if (hasPending) { + sink.add(pending as T); + if (isDone) { + sink.close(); + } else { + timer = Timer(duration, onTimer); + hasPending = false; + pending = null; + } + } else { + timer = null; + } + } + + if (timer == null) { + sink.add(data); + timer = Timer(duration, onTimer); + } else { + hasPending = true; + pending = data; + } + }, onDone: (sink) { + isDone = true; + if (hasPending) return; // Will be closed by timer. + sink.close(); + timer?.cancel(); + timer = null; + }); + } + + /// Audit a single event from each [duration] length period where there are + /// events on this stream. + /// + /// No events will ever be emitted within [duration] of another event on the + /// result stream. + /// If this stream is a broadcast stream, the result will be as well. + /// Errors are forwarded immediately. + /// + /// The first event will begin the audit period. At the end of the audit + /// period the most recent event is emitted, and the next event restarts the + /// audit period. + /// + /// If the event that started the period is the one that is emitted it will be + /// delayed by [duration]. If a later event comes in within the period it's + /// delay will be shorter by the difference in arrival times. + /// + /// If there is no pending event when this stream closes the output + /// stream will close immediately. If there is a pending event the output + /// stream will wait to emit it before closing. + /// + /// For example: + /// + /// source.audit(Duration(seconds: 5)); + /// + /// source: a------b--c----d--| + /// output: -----a------c--------d| + /// + /// See also: + /// - [throttle], which emits the _first_ event during the window, instead of + /// the last event in the window. Compared to `throttle`, `audit` will + /// introduce delay to forwarded events. + /// - [debounce], which only emits after the stream has not emitted for some + /// period. Compared to `debouce`, `audit` cannot be starved by having events + /// emitted continuously within [duration]. + Stream audit(Duration duration) { + Timer? timer; + var shouldClose = false; + T recentData; + + return transformByHandlers(onData: (data, sink) { + recentData = data; + timer ??= Timer(duration, () { + sink.add(recentData); + timer = null; + if (shouldClose) { + sink.close(); + } + }); + }, onDone: (sink) { + if (timer != null) { + shouldClose = true; + } else { + sink.close(); + } + }); + } + + /// Buffers the values emitted on this stream and emits them when [trigger] + /// emits an event. + /// + /// If [longPoll] is `false`, if there are no buffered values when [trigger] + /// emits an empty list is immediately emitted. + /// + /// If [longPoll] is `true`, and there are no buffered values when [trigger] + /// emits one or more events, then the *next* value from this stream is + /// immediately emitted on the returned stream as a single element list. + /// Subsequent events on [trigger] while there have been no events on this + /// stream are ignored. + /// + /// The result stream will close as soon as there is a guarantee it will not + /// emit any more events. There will not be any more events emitted if: + /// - [trigger] is closed and there is no waiting long poll. + /// - Or, this stream is closed and previously buffered events have been + /// delivered. + /// + /// If this stream is a broadcast stream, the result will be as well. + /// Errors from this stream or the trigger are immediately forwarded to the + /// output. + /// + /// See also: + /// - [sample] which use a [trigger] stream in the same way, but keeps only + /// the most recent source event. + Stream> buffer(Stream trigger, {bool longPoll = true}) => + aggregateSample( + trigger: trigger, + aggregate: _collect, + longPoll: longPoll, + onEmpty: _empty); + + /// Emits the most recent new value from this stream when [trigger] emits an + /// event. + /// + /// If [longPoll] is `false`, then an event on [trigger] when there is no + /// pending source event will be ignored. + /// If [longPoll] is `true` (the default), then an event on [trigger] when + /// there is no pending source event will cause the next source event + /// to immediately flow to the result stream. + /// + /// If [longPoll] is `false`, if there is no pending source event when + /// [trigger] emits, then the trigger event will be ignored. + /// + /// If [longPoll] is `true`, and there are no buffered values when [trigger] + /// emits one or more events, then the *next* value from this stream is + /// immediately emitted on the returned stream as a single element list. + /// Subsequent events on [trigger] while there have been no events on this + /// stream are ignored. + /// + /// The result stream will close as soon as there is a guarantee it will not + /// emit any more events. There will not be any more events emitted if: + /// - [trigger] is closed and there is no waiting long poll. + /// - Or, this source stream is closed and any pending source event has been + /// delivered. + /// + /// If this source stream is a broadcast stream, the result will be as well. + /// Errors from this source stream or the trigger are immediately forwarded to + /// the output. + /// + /// See also: + /// - [buffer] which use [trigger] stream in the same way, but keeps a list of + /// pending source events. + Stream sample(Stream trigger, {bool longPoll = true}) => + aggregateSample( + trigger: trigger, + aggregate: _dropPrevious, + longPoll: longPoll, + onEmpty: ignoreArgument); + + /// Aggregates values until this source stream does not emit for [duration], + /// then emits the aggregated values. + Stream _debounceAggregate( + Duration duration, S Function(T element, S? soFar) collect, + {required bool leading, required bool trailing}) { + Timer? timer; + S? soFar; + var hasPending = false; + var shouldClose = false; + var emittedLatestAsLeading = false; + + return transformByHandlers(onData: (value, sink) { + void emit() { + sink.add(soFar as S); + soFar = null; + hasPending = false; + } + + timer?.cancel(); + soFar = collect(value, soFar); + hasPending = true; + if (timer == null && leading) { + emittedLatestAsLeading = true; + emit(); + } else { + emittedLatestAsLeading = false; + } + timer = Timer(duration, () { + if (trailing && !emittedLatestAsLeading) emit(); + if (shouldClose) sink.close(); + timer = null; + }); + }, onDone: (EventSink sink) { + if (hasPending && trailing) { + shouldClose = true; + } else { + timer?.cancel(); + sink.close(); + } + }); + } +} + +T _dropPrevious(T element, _) => element; +List _collect(T event, List? soFar) => (soFar ?? [])..add(event); +void _empty(Sink> sink) => sink.add([]); diff --git a/pkgs/stream_transform/lib/src/scan.dart b/pkgs/stream_transform/lib/src/scan.dart new file mode 100644 index 000000000..acd3c7676 --- /dev/null +++ b/pkgs/stream_transform/lib/src/scan.dart @@ -0,0 +1,31 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +/// A utility similar to [fold] which emits intermediate accumulations. +extension Scan on Stream { + /// Emits a sequence of the accumulated values from repeatedly applying + /// [combine]. + /// + /// Like [fold], but instead of producing a single value it yields each + /// intermediate result. + /// + /// If [combine] returns a future it will not be called again for subsequent + /// events from the source until it completes, therefore [combine] is always + /// called for elements in order, and the result stream always maintains the + /// same order as this stream. + Stream scan( + S initialValue, FutureOr Function(S soFar, T element) combine) { + var accumulated = initialValue; + return asyncMap((value) { + var result = combine(accumulated, value); + if (result is Future) { + return result.then((r) => accumulated = r); + } else { + return accumulated = result; + } + }); + } +} diff --git a/pkgs/stream_transform/lib/src/switch.dart b/pkgs/stream_transform/lib/src/switch.dart new file mode 100644 index 000000000..546036e7c --- /dev/null +++ b/pkgs/stream_transform/lib/src/switch.dart @@ -0,0 +1,135 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'async_expand.dart'; +import 'common_callbacks.dart'; + +/// A utility to take events from the most recent sub stream returned by a +/// callback. +extension Switch on Stream { + /// Maps events to a Stream and emits values from the most recently created + /// Stream. + /// + /// When the source emits a value it will be converted to a [Stream] using + /// [convert] and the output will switch to emitting events from that result. + /// Like [asyncExpand] but the [Stream] emitted by a previous element + /// will be ignored as soon as the source stream emits a new event. + /// + /// This means that the source stream is not paused until a sub stream + /// returned from the [convert] callback is done. Instead, the subscription + /// to the sub stream is canceled as soon as the source stream emits a new + /// event. + /// + /// Errors from [convert], the source stream, or any of the sub streams are + /// forwarded to the result stream. + /// + /// The result stream will not close until the source stream closes and + /// the current sub stream have closed. + /// + /// If the source stream is a broadcast stream, the result will be as well, + /// regardless of the types of streams created by [convert]. In this case, + /// some care should be taken: + /// + /// * If [convert] returns a single subscription stream it may be listened to + /// and never canceled. + /// + /// See also: + /// - [concurrentAsyncExpand], which emits events from all sub streams + /// concurrently instead of cancelling subscriptions to previous subs + /// streams. + Stream switchMap(Stream Function(T) convert) { + return map(convert).switchLatest(); + } +} + +/// A utility to take events from the most recent sub stream. +extension SwitchLatest on Stream> { + /// Emits values from the most recently emitted Stream. + /// + /// When the source emits a stream, the output will switch to emitting events + /// from that stream. + /// + /// Whether the source stream is a single-subscription stream or a + /// broadcast stream, the result stream will be the same kind of stream, + /// regardless of the types of streams emitted. + Stream switchLatest() { + var controller = isBroadcast + ? StreamController.broadcast(sync: true) + : StreamController(sync: true); + + controller.onListen = () { + StreamSubscription? innerSubscription; + var outerStreamDone = false; + + void listenToInnerStream(Stream innerStream) { + assert(innerSubscription == null); + var subscription = innerStream + .listen(controller.add, onError: controller.addError, onDone: () { + innerSubscription = null; + if (outerStreamDone) controller.close(); + }); + // If a pause happens during an innerSubscription.cancel, + // we still listen to the next stream when the cancel is done. + // Then we immediately pause it again here. + if (controller.isPaused) subscription.pause(); + innerSubscription = subscription; + } + + var addError = controller.addError; + final outerSubscription = listen(null, onError: addError, onDone: () { + outerStreamDone = true; + if (innerSubscription == null) controller.close(); + }); + outerSubscription.onData((innerStream) async { + var currentSubscription = innerSubscription; + if (currentSubscription == null) { + listenToInnerStream(innerStream); + return; + } + innerSubscription = null; + outerSubscription.pause(); + try { + await currentSubscription.cancel(); + } catch (error, stack) { + controller.addError(error, stack); + } finally { + if (!isBroadcast && !controller.hasListener) { + // Result single-subscription stream subscription was cancelled + // while waiting for previous innerStream cancel. + // + // Ensure that the last received stream is also listened to and + // cancelled, then do nothing further. + innerStream.listen(null).cancel().ignore(); + } else { + outerSubscription.resume(); + listenToInnerStream(innerStream); + } + } + }); + if (!isBroadcast) { + controller + ..onPause = () { + innerSubscription?.pause(); + outerSubscription.pause(); + } + ..onResume = () { + innerSubscription?.resume(); + outerSubscription.resume(); + }; + } + controller.onCancel = () { + var sub = innerSubscription; + var cancels = [ + if (!outerStreamDone) outerSubscription.cancel(), + if (sub != null) sub.cancel(), + ]; + if (cancels.isEmpty) return null; + return cancels.wait.then(ignoreArgument); + }; + }; + return controller.stream; + } +} diff --git a/pkgs/stream_transform/lib/src/take_until.dart b/pkgs/stream_transform/lib/src/take_until.dart new file mode 100644 index 000000000..e6deaa135 --- /dev/null +++ b/pkgs/stream_transform/lib/src/take_until.dart @@ -0,0 +1,64 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +/// A utility to end a stream based on an external trigger. +extension TakeUntil on Stream { + /// Takes values from this stream which are emitted before [trigger] + /// completes. + /// + /// Completing [trigger] differs from canceling a subscription in that values + /// which are emitted before the trigger, but have further asynchronous delays + /// in transformations following the takeUtil, will still go through. + /// Cancelling a subscription immediately stops values. + /// + /// If [trigger] completes as an error, the error will be forwarded through + /// the result stream before the result stream closes. + /// + /// If [trigger] completes as a value or as an error after this stream has + /// already ended, the completion will be ignored. + Stream takeUntil(Future trigger) { + var controller = isBroadcast + ? StreamController.broadcast(sync: true) + : StreamController(sync: true); + + StreamSubscription? subscription; + var isDone = false; + trigger.then((_) { + if (isDone) return; + isDone = true; + subscription?.cancel(); + controller.close(); + }, onError: (Object error, StackTrace stackTrace) { + if (isDone) return; + isDone = true; + controller + ..addError(error, stackTrace) + ..close(); + }); + + controller.onListen = () { + if (isDone) return; + subscription = + listen(controller.add, onError: controller.addError, onDone: () { + if (isDone) return; + isDone = true; + controller.close(); + }); + if (!isBroadcast) { + controller + ..onPause = subscription!.pause + ..onResume = subscription!.resume; + } + controller.onCancel = () { + if (isDone) return null; + var toCancel = subscription!; + subscription = null; + return toCancel.cancel(); + }; + }; + return controller.stream; + } +} diff --git a/pkgs/stream_transform/lib/src/tap.dart b/pkgs/stream_transform/lib/src/tap.dart new file mode 100644 index 000000000..4b16ab5fd --- /dev/null +++ b/pkgs/stream_transform/lib/src/tap.dart @@ -0,0 +1,44 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'from_handlers.dart'; + +/// A utility to chain extra behavior on a stream. +extension Tap on Stream { + /// Taps into this stream to allow additional handling on a single-subscriber + /// stream without first wrapping as a broadcast stream. + /// + /// The [onValue] callback will be called with every value from this stream + /// before it is forwarded to listeners on the resulting stream. + /// May be null if only [onError] or [onDone] callbacks are needed. + /// + /// The [onError] callback will be called with every error from this stream + /// before it is forwarded to listeners on the resulting stream. + /// + /// The [onDone] callback will be called after this stream closes and before + /// the resulting stream is closed. + /// + /// Errors from any of the callbacks are caught and ignored. + /// + /// The callbacks may not be called until the tapped stream has a listener, + /// and may not be called after the listener has canceled the subscription. + Stream tap(void Function(T)? onValue, + {void Function(Object, StackTrace)? onError, + void Function()? onDone}) => + transformByHandlers(onData: (value, sink) { + try { + onValue?.call(value); + } catch (_) {/*Ignore*/} + sink.add(value); + }, onError: (error, stackTrace, sink) { + try { + onError?.call(error, stackTrace); + } catch (_) {/*Ignore*/} + sink.addError(error, stackTrace); + }, onDone: (sink) { + try { + onDone?.call(); + } catch (_) {/*Ignore*/} + sink.close(); + }); +} diff --git a/pkgs/stream_transform/lib/src/where.dart b/pkgs/stream_transform/lib/src/where.dart new file mode 100644 index 000000000..76aa28a8f --- /dev/null +++ b/pkgs/stream_transform/lib/src/where.dart @@ -0,0 +1,71 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'from_handlers.dart'; + +/// Utilities to filter events. +extension Where on Stream { + /// Discards events from this stream that are not of type [S]. + /// + /// If the source stream is a broadcast stream the result will be as well. + /// + /// Errors from the source stream are forwarded directly to the result stream. + /// + /// [S] should be a subtype of the stream's generic type, otherwise nothing of + /// type [S] could possibly be emitted, however there is no static or runtime + /// checking that this is the case. + Stream whereType() => transformByHandlers(onData: (event, sink) { + if (event is S) sink.add(event); + }); + + /// Discards events from this stream based on an asynchronous [test] callback. + /// + /// Like [where] but allows the [test] to return a [Future]. + /// + /// Events on the result stream will be emitted in the order that [test] + /// completes which may not match the order of this stream. + /// + /// If the source stream is a broadcast stream the result will be as well. + /// When used with a broadcast stream behavior also differs from [where] in + /// that the [test] function is only called once per event, rather than once + /// per listener per event. + /// + /// Errors from the source stream are forwarded directly to the result stream. + /// Errors from [test] are also forwarded to the result stream. + /// + /// The result stream will not close until the source stream closes and all + /// pending [test] calls have finished. + Stream asyncWhere(FutureOr Function(T) test) { + var valuesWaiting = 0; + var sourceDone = false; + return transformByHandlers(onData: (element, sink) { + valuesWaiting++; + () async { + try { + if (await test(element)) sink.add(element); + } catch (e, st) { + sink.addError(e, st); + } + valuesWaiting--; + if (valuesWaiting <= 0 && sourceDone) sink.close(); + }(); + }, onDone: (sink) { + sourceDone = true; + if (valuesWaiting <= 0) sink.close(); + }); + } +} + +extension WhereNotNull on Stream { + /// Discards `null` events from this stream. + /// + /// If the source stream is a broadcast stream the result will be as well. + /// + /// Errors from the source stream are forwarded directly to the result stream. + Stream whereNotNull() => transformByHandlers(onData: (event, sink) { + if (event != null) sink.add(event); + }); +} diff --git a/pkgs/stream_transform/lib/stream_transform.dart b/pkgs/stream_transform/lib/stream_transform.dart new file mode 100644 index 000000000..edf4df9e4 --- /dev/null +++ b/pkgs/stream_transform/lib/stream_transform.dart @@ -0,0 +1,15 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'src/async_expand.dart'; +export 'src/async_map.dart'; +export 'src/combine_latest.dart'; +export 'src/concatenate.dart'; +export 'src/merge.dart'; +export 'src/rate_limit.dart'; +export 'src/scan.dart'; +export 'src/switch.dart'; +export 'src/take_until.dart'; +export 'src/tap.dart'; +export 'src/where.dart'; diff --git a/pkgs/stream_transform/pubspec.yaml b/pkgs/stream_transform/pubspec.yaml new file mode 100644 index 000000000..1e2298abe --- /dev/null +++ b/pkgs/stream_transform/pubspec.yaml @@ -0,0 +1,13 @@ +name: stream_transform +version: 2.1.1 +description: A collection of utilities to transform and manipulate streams. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/stream_transform + +environment: + sdk: ^3.1.0 + +dev_dependencies: + async: ^2.5.0 + dart_flutter_team_lints: ^2.0.0 + fake_async: ^1.3.0 + test: ^1.16.0 diff --git a/pkgs/stream_transform/test/async_expand_test.dart b/pkgs/stream_transform/test/async_expand_test.dart new file mode 100644 index 000000000..8d84300ec --- /dev/null +++ b/pkgs/stream_transform/test/async_expand_test.dart @@ -0,0 +1,195 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + test('forwards errors from the convert callback', () async { + var errors = []; + var source = Stream.fromIterable([1, 2, 3]); + source.concurrentAsyncExpand((i) { + // ignore: only_throw_errors + throw 'Error: $i'; + }).listen((_) {}, onError: errors.add); + await Future(() {}); + expect(errors, ['Error: 1', 'Error: 2', 'Error: 3']); + }); + + for (var outerType in streamTypes) { + for (var innerType in streamTypes) { + group('concurrentAsyncExpand $outerType to $innerType', () { + late StreamController outerController; + late bool outerCanceled; + late List> innerControllers; + late List innerCanceled; + late List emittedValues; + late bool isDone; + late List errors; + late Stream transformed; + late StreamSubscription subscription; + + setUp(() { + outerController = createController(outerType) + ..onCancel = () { + outerCanceled = true; + }; + outerCanceled = false; + innerControllers = []; + innerCanceled = []; + emittedValues = []; + errors = []; + isDone = false; + transformed = outerController.stream.concurrentAsyncExpand((i) { + var index = innerControllers.length; + innerCanceled.add(false); + innerControllers.add(createController(innerType) + ..onCancel = () { + innerCanceled[index] = true; + }); + return innerControllers.last.stream; + }); + subscription = transformed + .listen(emittedValues.add, onError: errors.add, onDone: () { + isDone = true; + }); + }); + + test('interleaves events from sub streams', () async { + outerController + ..add(1) + ..add(2); + await Future(() {}); + expect(emittedValues, isEmpty); + expect(innerControllers, hasLength(2)); + innerControllers[0].add('First'); + innerControllers[1].add('Second'); + innerControllers[0].add('First again'); + await Future(() {}); + expect(emittedValues, ['First', 'Second', 'First again']); + }); + + test('forwards errors from outer stream', () async { + outerController.addError('Error'); + await Future(() {}); + expect(errors, ['Error']); + }); + + test('forwards errors from inner streams', () async { + outerController + ..add(1) + ..add(2); + await Future(() {}); + innerControllers[0].addError('Error 1'); + innerControllers[1].addError('Error 2'); + await Future(() {}); + expect(errors, ['Error 1', 'Error 2']); + }); + + test('can continue handling events after an error in outer stream', + () async { + outerController + ..addError('Error') + ..add(1); + await Future(() {}); + innerControllers[0].add('First'); + await Future(() {}); + expect(emittedValues, ['First']); + expect(errors, ['Error']); + }); + + test('cancels outer subscription if output canceled', () async { + await subscription.cancel(); + expect(outerCanceled, true); + }); + + if (outerType != 'broadcast' || innerType != 'single subscription') { + // A single subscription inner stream in a broadcast outer stream is + // not canceled. + test('cancels inner subscriptions if output canceled', () async { + outerController + ..add(1) + ..add(2); + await Future(() {}); + await subscription.cancel(); + expect(innerCanceled, [true, true]); + }); + } + + test('stays open if any inner stream is still open', () async { + outerController.add(1); + await outerController.close(); + await Future(() {}); + expect(isDone, false); + }); + + test('stays open if outer stream is still open', () async { + outerController.add(1); + await Future(() {}); + await innerControllers[0].close(); + await Future(() {}); + expect(isDone, false); + }); + + test('closes after all inner streams and outer stream close', () async { + outerController.add(1); + await Future(() {}); + await innerControllers[0].close(); + await outerController.close(); + await Future(() {}); + expect(isDone, true); + }); + + if (outerType == 'broadcast') { + test('multiple listerns all get values', () async { + var otherValues = []; + transformed.listen(otherValues.add); + outerController.add(1); + await Future(() {}); + innerControllers[0].add('First'); + await Future(() {}); + expect(emittedValues, ['First']); + expect(otherValues, ['First']); + }); + + test('multiple listeners get closed', () async { + var otherDone = false; + transformed.listen(null, onDone: () => otherDone = true); + outerController.add(1); + await Future(() {}); + await innerControllers[0].close(); + await outerController.close(); + await Future(() {}); + expect(isDone, true); + expect(otherDone, true); + }); + + test('can cancel and relisten', () async { + outerController + ..add(1) + ..add(2); + await Future(() {}); + innerControllers[0].add('First'); + innerControllers[1].add('Second'); + await Future(() {}); + await subscription.cancel(); + innerControllers[0].add('Ignored'); + await Future(() {}); + subscription = transformed.listen(emittedValues.add); + innerControllers[0].add('Also ignored'); + outerController.add(3); + await Future(() {}); + innerControllers[2].add('More'); + await Future(() {}); + expect(emittedValues, ['First', 'Second', 'More']); + }); + } + }); + } + } +} diff --git a/pkgs/stream_transform/test/async_map_buffer_test.dart b/pkgs/stream_transform/test/async_map_buffer_test.dart new file mode 100644 index 000000000..238621760 --- /dev/null +++ b/pkgs/stream_transform/test/async_map_buffer_test.dart @@ -0,0 +1,204 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + late StreamController values; + late List emittedValues; + late bool valuesCanceled; + late bool isDone; + late List errors; + late Stream transformed; + late StreamSubscription subscription; + + Completer? finishWork; + List? workArgument; + + /// Represents the async `convert` function and asserts that is is only called + /// after the previous iteration has completed. + Future work(List values) { + expect(finishWork, isNull, + reason: 'See $values befor previous work is complete'); + workArgument = values; + finishWork = Completer() + ..future.then((_) { + workArgument = null; + finishWork = null; + }).catchError((_) { + workArgument = null; + finishWork = null; + }); + return finishWork!.future; + } + + for (var streamType in streamTypes) { + group('asyncMapBuffer for stream type: [$streamType]', () { + setUp(() { + valuesCanceled = false; + values = createController(streamType) + ..onCancel = () { + valuesCanceled = true; + }; + emittedValues = []; + errors = []; + isDone = false; + finishWork = null; + workArgument = null; + transformed = values.stream.asyncMapBuffer(work); + subscription = transformed + .listen(emittedValues.add, onError: errors.add, onDone: () { + isDone = true; + }); + }); + + test('does not emit before work finishes', () async { + values.add(1); + await Future(() {}); + expect(emittedValues, isEmpty); + expect(workArgument, [1]); + finishWork!.complete('result'); + await Future(() {}); + expect(emittedValues, ['result']); + }); + + test('buffers values while work is ongoing', () async { + values.add(1); + await Future(() {}); + values + ..add(2) + ..add(3); + await Future(() {}); + finishWork!.complete(''); + await Future(() {}); + expect(workArgument, [2, 3]); + }); + + test('forwards errors without waiting for work', () async { + values.add(1); + await Future(() {}); + values.addError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + + test('forwards errors which occur during the work', () async { + values.add(1); + await Future(() {}); + finishWork!.completeError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + + test('can continue handling events after an error', () async { + values.add(1); + await Future(() {}); + finishWork!.completeError('error'); + values.add(2); + await Future(() {}); + expect(workArgument, [2]); + finishWork!.completeError('another'); + await Future(() {}); + expect(errors, ['error', 'another']); + }); + + test('does not start next work early due to an error in values', + () async { + values.add(1); + await Future(() {}); + values + ..addError('error') + ..add(2); + await Future(() {}); + expect(errors, ['error']); + // [work] will assert that the second iteration is not called because + // the first has not completed. + }); + + test('cancels value subscription when output canceled', () async { + expect(valuesCanceled, false); + await subscription.cancel(); + expect(valuesCanceled, true); + }); + + test('closes when values end if no work is pending', () async { + expect(isDone, false); + await values.close(); + await Future(() {}); + expect(isDone, true); + }); + + test('waits for pending work when values close', () async { + values.add(1); + await Future(() {}); + expect(isDone, false); + values.add(2); + await values.close(); + expect(isDone, false); + finishWork!.complete(''); + await Future(() {}); + // Still a pending value + expect(isDone, false); + finishWork!.complete(''); + await Future(() {}); + expect(isDone, true); + }); + + test('forwards errors from values', () async { + values.addError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + + if (streamType == 'broadcast') { + test('multiple listeners all get values', () async { + var otherValues = []; + transformed.listen(otherValues.add); + values.add(1); + await Future(() {}); + finishWork!.complete('result'); + await Future(() {}); + expect(emittedValues, ['result']); + expect(otherValues, ['result']); + }); + + test('multiple listeners get done when values end', () async { + var otherDone = false; + transformed.listen(null, onDone: () => otherDone = true); + values.add(1); + await Future(() {}); + await values.close(); + expect(isDone, false); + expect(otherDone, false); + finishWork!.complete(''); + await Future(() {}); + expect(isDone, true); + expect(otherDone, true); + }); + + test('can cancel and relisten', () async { + values.add(1); + await Future(() {}); + finishWork!.complete('first'); + await Future(() {}); + await subscription.cancel(); + values.add(2); + await Future(() {}); + subscription = transformed.listen(emittedValues.add); + values.add(3); + await Future(() {}); + expect(workArgument, [3]); + finishWork!.complete('second'); + await Future(() {}); + expect(emittedValues, ['first', 'second']); + }); + } + }); + } +} diff --git a/pkgs/stream_transform/test/async_map_sample_test.dart b/pkgs/stream_transform/test/async_map_sample_test.dart new file mode 100644 index 000000000..62b1b92cf --- /dev/null +++ b/pkgs/stream_transform/test/async_map_sample_test.dart @@ -0,0 +1,209 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + late StreamController values; + late List emittedValues; + late bool valuesCanceled; + late bool isDone; + late List errors; + late Stream transformed; + late StreamSubscription subscription; + + Completer? finishWork; + int? workArgument; + + /// Represents the async `convert` function and asserts that is is only called + /// after the previous iteration has completed. + Future work(int value) { + expect(finishWork, isNull, + reason: 'See $values befor previous work is complete'); + workArgument = value; + finishWork = Completer() + ..future.then((_) { + workArgument = null; + finishWork = null; + }).catchError((_) { + workArgument = null; + finishWork = null; + }); + return finishWork!.future; + } + + for (var streamType in streamTypes) { + group('asyncMapSample for stream type: [$streamType]', () { + setUp(() { + valuesCanceled = false; + values = createController(streamType) + ..onCancel = () { + valuesCanceled = true; + }; + emittedValues = []; + errors = []; + isDone = false; + finishWork = null; + workArgument = null; + transformed = values.stream.asyncMapSample(work); + subscription = transformed + .listen(emittedValues.add, onError: errors.add, onDone: () { + isDone = true; + }); + }); + + test('does not emit before work finishes', () async { + values.add(1); + await Future(() {}); + expect(emittedValues, isEmpty); + expect(workArgument, 1); + finishWork!.complete('result'); + await Future(() {}); + expect(emittedValues, ['result']); + }); + + test('buffers values while work is ongoing', () async { + values.add(1); + await Future(() {}); + values + ..add(2) + ..add(3); + await Future(() {}); + finishWork!.complete(''); + await Future(() {}); + expect(workArgument, 3); + }); + + test('forwards errors without waiting for work', () async { + values.add(1); + await Future(() {}); + values.addError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + + test('forwards errors which occur during the work', () async { + values.add(1); + await Future(() {}); + finishWork!.completeError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + + test('can continue handling events after an error', () async { + values.add(1); + await Future(() {}); + finishWork!.completeError('error'); + values.add(2); + await Future(() {}); + expect(workArgument, 2); + finishWork!.completeError('another'); + await Future(() {}); + expect(errors, ['error', 'another']); + }); + + test('does not start next work early due to an error in values', + () async { + values.add(1); + await Future(() {}); + values + ..addError('error') + ..add(2); + await Future(() {}); + expect(errors, ['error']); + // [work] will assert that the second iteration is not called because + // the first has not completed. + }); + + test('cancels value subscription when output canceled', () async { + expect(valuesCanceled, false); + await subscription.cancel(); + expect(valuesCanceled, true); + }); + + test('closes when values end if no work is pending', () async { + expect(isDone, false); + await values.close(); + await Future(() {}); + expect(isDone, true); + }); + + test('waits for pending work when values close', () async { + values.add(1); + await Future(() {}); + expect(isDone, false); + values.add(2); + await values.close(); + expect(isDone, false); + finishWork!.complete(''); + await Future(() {}); + // Still a pending value + expect(isDone, false); + finishWork!.complete(''); + await Future(() {}); + expect(isDone, true); + }); + + test('forwards errors from values', () async { + values.addError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + + if (streamType == 'broadcast') { + test('multiple listeners all get values', () async { + var otherValues = []; + transformed.listen(otherValues.add); + values.add(1); + await Future(() {}); + finishWork!.complete('result'); + await Future(() {}); + expect(emittedValues, ['result']); + expect(otherValues, ['result']); + }); + + test('multiple listeners get done when values end', () async { + var otherDone = false; + transformed.listen(null, onDone: () => otherDone = true); + values.add(1); + await Future(() {}); + await values.close(); + expect(isDone, false); + expect(otherDone, false); + finishWork!.complete(''); + await Future(() {}); + expect(isDone, true); + expect(otherDone, true); + }); + + test('can cancel and relisten', () async { + values.add(1); + await Future(() {}); + finishWork!.complete('first'); + await Future(() {}); + await subscription.cancel(); + values.add(2); + await Future(() {}); + subscription = transformed.listen(emittedValues.add); + values.add(3); + await Future(() {}); + expect(workArgument, 3); + finishWork!.complete('second'); + await Future(() {}); + expect(emittedValues, ['first', 'second']); + }); + } + }); + } + + test('allows nulls', () async { + var stream = Stream.value(null); + await stream.asyncMapSample(expectAsync1((_) async {})).drain(); + }); +} diff --git a/pkgs/stream_transform/test/async_where_test.dart b/pkgs/stream_transform/test/async_where_test.dart new file mode 100644 index 000000000..6ea4e76ed --- /dev/null +++ b/pkgs/stream_transform/test/async_where_test.dart @@ -0,0 +1,90 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +void main() { + test('forwards only events that pass the predicate', () async { + var values = Stream.fromIterable([1, 2, 3, 4]); + var filtered = values.asyncWhere((e) async => e > 2); + expect(await filtered.toList(), [3, 4]); + }); + + test('allows predicates that go through event loop', () async { + var values = Stream.fromIterable([1, 2, 3, 4]); + var filtered = values.asyncWhere((e) async { + await Future(() {}); + return e > 2; + }); + expect(await filtered.toList(), [3, 4]); + }); + + test('allows synchronous predicate', () async { + var values = Stream.fromIterable([1, 2, 3, 4]); + var filtered = values.asyncWhere((e) => e > 2); + expect(await filtered.toList(), [3, 4]); + }); + + test('can result in empty stream', () async { + var values = Stream.fromIterable([1, 2, 3, 4]); + var filtered = values.asyncWhere((e) => e > 4); + expect(await filtered.isEmpty, true); + }); + + test('forwards values to multiple listeners', () async { + var values = StreamController.broadcast(); + var filtered = values.stream.asyncWhere((e) async => e > 2); + var firstValues = []; + var secondValues = []; + filtered + ..listen(firstValues.add) + ..listen(secondValues.add); + values + ..add(1) + ..add(2) + ..add(3) + ..add(4); + await Future(() {}); + expect(firstValues, [3, 4]); + expect(secondValues, [3, 4]); + }); + + test('closes streams with multiple listeners', () async { + var values = StreamController.broadcast(); + var predicate = Completer(); + var filtered = values.stream.asyncWhere((_) => predicate.future); + var firstDone = false; + var secondDone = false; + filtered + ..listen(null, onDone: () => firstDone = true) + ..listen(null, onDone: () => secondDone = true); + values.add(1); + await values.close(); + expect(firstDone, false); + expect(secondDone, false); + + predicate.complete(true); + await Future(() {}); + expect(firstDone, true); + expect(secondDone, true); + }); + + test('forwards errors emitted by the test callback', () async { + var errors = []; + var emitted = []; + var values = Stream.fromIterable([1, 2, 3, 4]); + var filtered = values.asyncWhere((e) async { + await Future(() {}); + if (e.isEven) throw Exception('$e'); + return true; + }); + var done = Completer(); + filtered.listen(emitted.add, onError: errors.add, onDone: done.complete); + await done.future; + expect(emitted, [1, 3]); + expect(errors.map((e) => '$e'), ['Exception: 2', 'Exception: 4']); + }); +} diff --git a/pkgs/stream_transform/test/audit_test.dart b/pkgs/stream_transform/test/audit_test.dart new file mode 100644 index 000000000..28537db86 --- /dev/null +++ b/pkgs/stream_transform/test/audit_test.dart @@ -0,0 +1,140 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + for (var streamType in streamTypes) { + group('Stream type [$streamType]', () { + late StreamController values; + late List emittedValues; + late bool valuesCanceled; + late bool isDone; + late List errors; + late Stream transformed; + late StreamSubscription subscription; + + group('audit', () { + setUp(() { + valuesCanceled = false; + values = createController(streamType) + ..onCancel = () { + valuesCanceled = true; + }; + emittedValues = []; + errors = []; + isDone = false; + transformed = values.stream.audit(const Duration(milliseconds: 6)); + }); + + void listen() { + subscription = transformed + .listen(emittedValues.add, onError: errors.add, onDone: () { + isDone = true; + }); + } + + test('cancels values', () async { + listen(); + await subscription.cancel(); + expect(valuesCanceled, true); + }); + + test('swallows values that come faster than duration', () { + fakeAsync((async) { + listen(); + values + ..add(1) + ..add(2) + ..close(); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [2]); + }); + }); + + test('outputs multiple values spaced further than duration', () { + fakeAsync((async) { + listen(); + values.add(1); + async.elapse(const Duration(milliseconds: 6)); + values.add(2); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [1, 2]); + }); + }); + + test('waits for pending value to close', () { + fakeAsync((async) { + listen(); + values + ..add(1) + ..close(); + expect(isDone, false); + async.elapse(const Duration(milliseconds: 6)); + expect(isDone, true); + }); + }); + + test('closes output if there are no pending values', () { + fakeAsync((async) { + listen(); + values.add(1); + async.elapse(const Duration(milliseconds: 6)); + values + ..add(2) + ..close(); + expect(isDone, false); + expect(emittedValues, [1]); + async.elapse(const Duration(milliseconds: 6)); + expect(isDone, true); + expect(emittedValues, [1, 2]); + }); + }); + + test('does not starve output if many values come closer than duration', + () { + fakeAsync((async) { + listen(); + values.add(1); + async.elapse(const Duration(milliseconds: 3)); + values.add(2); + async.elapse(const Duration(milliseconds: 3)); + values.add(3); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [2, 3]); + }); + }); + + if (streamType == 'broadcast') { + test('multiple listeners all get the values', () { + fakeAsync((async) { + listen(); + values.add(1); + async.elapse(const Duration(milliseconds: 3)); + values.add(2); + var otherValues = []; + transformed.listen(otherValues.add); + values.add(3); + async.elapse(const Duration(milliseconds: 3)); + values.add(4); + async.elapse(const Duration(milliseconds: 3)); + values + ..add(5) + ..close(); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [3, 5]); + expect(otherValues, [3, 5]); + }); + }); + } + }); + }); + } +} diff --git a/pkgs/stream_transform/test/buffer_test.dart b/pkgs/stream_transform/test/buffer_test.dart new file mode 100644 index 000000000..830f55539 --- /dev/null +++ b/pkgs/stream_transform/test/buffer_test.dart @@ -0,0 +1,305 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + late StreamController trigger; + late StreamController values; + late List> emittedValues; + late bool valuesCanceled; + late bool triggerCanceled; + late bool triggerPaused; + late bool isDone; + late List errors; + late Stream> transformed; + late StreamSubscription> subscription; + + void setUpForStreamTypes(String triggerType, String valuesType, + {required bool longPoll}) { + valuesCanceled = false; + triggerCanceled = false; + triggerPaused = false; + trigger = createController(triggerType) + ..onCancel = () { + triggerCanceled = true; + }; + if (triggerType == 'single subscription') { + trigger.onPause = () { + triggerPaused = true; + }; + } + values = createController(valuesType) + ..onCancel = () { + valuesCanceled = true; + }; + emittedValues = []; + errors = []; + isDone = false; + transformed = values.stream.buffer(trigger.stream, longPoll: longPoll); + subscription = + transformed.listen(emittedValues.add, onError: errors.add, onDone: () { + isDone = true; + }); + } + + for (var triggerType in streamTypes) { + for (var valuesType in streamTypes) { + group('Trigger type: [$triggerType], Values type: [$valuesType]', () { + group('general behavior', () { + setUp(() { + setUpForStreamTypes(triggerType, valuesType, longPoll: true); + }); + + test('does not emit before `trigger`', () async { + values.add(1); + await Future(() {}); + expect(emittedValues, isEmpty); + trigger.add(null); + await Future(() {}); + expect(emittedValues, [ + [1] + ]); + }); + + test('groups values between trigger', () async { + values + ..add(1) + ..add(2); + await Future(() {}); + trigger.add(null); + values + ..add(3) + ..add(4); + await Future(() {}); + trigger.add(null); + await Future(() {}); + expect(emittedValues, [ + [1, 2], + [3, 4] + ]); + }); + + test('cancels value subscription when output canceled', () async { + expect(valuesCanceled, false); + await subscription.cancel(); + expect(valuesCanceled, true); + }); + + test('closes when trigger ends', () async { + expect(isDone, false); + await trigger.close(); + await Future(() {}); + expect(isDone, true); + }); + + test('closes after outputting final values when source closes', + () async { + expect(isDone, false); + values.add(1); + await values.close(); + expect(isDone, false); + trigger.add(null); + await Future(() {}); + expect(emittedValues, [ + [1] + ]); + expect(isDone, true); + }); + + test('closes when source closes and there are no buffered', () async { + expect(isDone, false); + await values.close(); + await Future(() {}); + expect(isDone, true); + }); + + test('forwards errors from trigger', () async { + trigger.addError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + + test('forwards errors from values', () async { + values.addError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + }); + + group('long polling', () { + setUp(() { + setUpForStreamTypes(triggerType, valuesType, longPoll: true); + }); + + test('emits immediately if trigger emits before a value', () async { + trigger.add(null); + await Future(() {}); + expect(emittedValues, isEmpty); + values.add(1); + await Future(() {}); + expect(emittedValues, [ + [1] + ]); + }); + + test('two triggers in a row - emit buffere then emit next value', + () async { + values + ..add(1) + ..add(2); + await Future(() {}); + trigger + ..add(null) + ..add(null); + await Future(() {}); + values.add(3); + await Future(() {}); + expect(emittedValues, [ + [1, 2], + [3] + ]); + }); + + test('pre-emptive trigger then trigger after values', () async { + trigger.add(null); + await Future(() {}); + values + ..add(1) + ..add(2); + await Future(() {}); + trigger.add(null); + await Future(() {}); + expect(emittedValues, [ + [1], + [2] + ]); + }); + + test('multiple pre-emptive triggers, only emits first value', + () async { + trigger + ..add(null) + ..add(null); + await Future(() {}); + values + ..add(1) + ..add(2); + await Future(() {}); + expect(emittedValues, [ + [1] + ]); + }); + + test('closes if there is no waiting long poll when source closes', + () async { + expect(isDone, false); + values.add(1); + trigger.add(null); + await values.close(); + await Future(() {}); + expect(isDone, true); + }); + + test('waits to emit if there waiting long poll when trigger closes', + () async { + trigger.add(null); + await trigger.close(); + expect(isDone, false); + values.add(1); + await Future(() {}); + expect(emittedValues, [ + [1] + ]); + expect(isDone, true); + }); + }); + + group('immediate polling', () { + setUp(() { + setUpForStreamTypes(triggerType, valuesType, longPoll: false); + }); + + test('emits empty list before values', () async { + trigger.add(null); + await Future(() {}); + expect(emittedValues, [[]]); + }); + + test('emits empty list after emitting values', () async { + values + ..add(1) + ..add(2); + await Future(() {}); + trigger + ..add(null) + ..add(null); + await Future(() {}); + expect(emittedValues, [ + [1, 2], + [] + ]); + }); + }); + }); + } + } + + test('always cancels trigger if values is singlesubscription', () async { + setUpForStreamTypes('broadcast', 'single subscription', longPoll: true); + expect(triggerCanceled, false); + await subscription.cancel(); + expect(triggerCanceled, true); + + setUpForStreamTypes('single subscription', 'single subscription', + longPoll: true); + expect(triggerCanceled, false); + await subscription.cancel(); + expect(triggerCanceled, true); + }); + + test('cancels trigger if trigger is broadcast', () async { + setUpForStreamTypes('broadcast', 'broadcast', longPoll: true); + expect(triggerCanceled, false); + await subscription.cancel(); + expect(triggerCanceled, true); + }); + + test('pauses single subscription trigger for broadcast values', () async { + setUpForStreamTypes('single subscription', 'broadcast', longPoll: true); + expect(triggerCanceled, false); + expect(triggerPaused, false); + await subscription.cancel(); + expect(triggerCanceled, false); + expect(triggerPaused, true); + }); + + for (var triggerType in streamTypes) { + test('cancel and relisten with [$triggerType] trigger', () async { + setUpForStreamTypes(triggerType, 'broadcast', longPoll: true); + values.add(1); + trigger.add(null); + await Future(() {}); + expect(emittedValues, [ + [1] + ]); + await subscription.cancel(); + values.add(2); + trigger.add(null); + await Future(() {}); + subscription = transformed.listen(emittedValues.add); + values.add(3); + trigger.add(null); + await Future(() {}); + expect(emittedValues, [ + [1], + [3] + ]); + }); + } +} diff --git a/pkgs/stream_transform/test/combine_latest_all_test.dart b/pkgs/stream_transform/test/combine_latest_all_test.dart new file mode 100644 index 000000000..f4b719c7a --- /dev/null +++ b/pkgs/stream_transform/test/combine_latest_all_test.dart @@ -0,0 +1,166 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +Future tick() => Future(() {}); + +void main() { + group('combineLatestAll', () { + test('emits latest values', () async { + final first = StreamController(); + final second = StreamController(); + final third = StreamController(); + final combined = first.stream.combineLatestAll( + [second.stream, third.stream]).map((data) => data.join()); + + // first: a----b------------------c--------d---| + // second: --1---------2-----------------| + // third: -------&----------%---| + // combined: -------b1&--b2&---b2%---c2%------d2%-| + + expect(combined, + emitsInOrder(['b1&', 'b2&', 'b2%', 'c2%', 'd2%', emitsDone])); + + first.add('a'); + await tick(); + second.add('1'); + await tick(); + first.add('b'); + await tick(); + third.add('&'); + await tick(); + second.add('2'); + await tick(); + third.add('%'); + await tick(); + await third.close(); + await tick(); + first.add('c'); + await tick(); + await second.close(); + await tick(); + first.add('d'); + await tick(); + await first.close(); + }); + + test('ends if a Stream closes without ever emitting a value', () async { + final first = StreamController(); + final second = StreamController(); + final combined = first.stream.combineLatestAll([second.stream]); + + // first: -a------b-------| + // second: -----| + // combined: -----| + + expect(combined, emits(emitsDone)); + + first.add('a'); + await tick(); + await second.close(); + await tick(); + first.add('b'); + }); + + test('forwards errors', () async { + final first = StreamController(); + final second = StreamController(); + final combined = first.stream + .combineLatestAll([second.stream]).map((data) => data.join()); + + // first: -a---------| + // second: ----1---# + // combined: ----a1--# + + expect(combined, emitsThrough(emitsError('doh'))); + + first.add('a'); + await tick(); + second.add('1'); + await tick(); + second.addError('doh'); + }); + + test('ends after both streams have ended', () async { + final first = StreamController(); + final second = StreamController(); + + var done = false; + first.stream.combineLatestAll([second.stream]).listen(null, + onDone: () => done = true); + + // first: -a---| + // second: --------1--| + // combined: --------a1-| + + first.add('a'); + await tick(); + await first.close(); + await tick(); + + expect(done, isFalse); + + second.add('1'); + await tick(); + await second.close(); + await tick(); + + expect(done, isTrue); + }); + + group('broadcast source', () { + test('can cancel and relisten to broadcast stream', () async { + final first = StreamController.broadcast(); + final second = StreamController.broadcast(); + final combined = first.stream + .combineLatestAll([second.stream]).map((data) => data.join()); + + // first: a------b----------------c------d----e---| + // second: --1---------2---3---4------5-| + // combined: --a1---b1---b2--b3--b4-----c5--d5---e5--| + // sub1: ^-----------------! + // sub2: ----------------------^-----------------| + + expect(combined.take(4), emitsInOrder(['a1', 'b1', 'b2', 'b3'])); + + first.add('a'); + await tick(); + second.add('1'); + await tick(); + first.add('b'); + await tick(); + second.add('2'); + await tick(); + second.add('3'); + await tick(); + + // First subscription is canceled here by .take(4) + expect(first.hasListener, isFalse); + expect(second.hasListener, isFalse); + + // This emit is thrown away because there are no subscribers + second.add('4'); + await tick(); + + expect(combined, emitsInOrder(['c5', 'd5', 'e5', emitsDone])); + + first.add('c'); + await tick(); + second.add('5'); + await tick(); + await second.close(); + await tick(); + first.add('d'); + await tick(); + first.add('e'); + await tick(); + await first.close(); + }); + }); + }); +} diff --git a/pkgs/stream_transform/test/combine_latest_test.dart b/pkgs/stream_transform/test/combine_latest_test.dart new file mode 100644 index 000000000..1985c7587 --- /dev/null +++ b/pkgs/stream_transform/test/combine_latest_test.dart @@ -0,0 +1,179 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +void main() { + group('combineLatest', () { + test('flows through combine callback', () async { + var source = StreamController(); + var other = StreamController(); + int sum(int a, int b) => a + b; + + var results = []; + unawaited( + source.stream.combineLatest(other.stream, sum).forEach(results.add)); + + source.add(1); + await Future(() {}); + expect(results, isEmpty); + + other.add(2); + await Future(() {}); + expect(results, [3]); + + source.add(3); + await Future(() {}); + expect(results, [3, 5]); + + source.add(4); + await Future(() {}); + expect(results, [3, 5, 6]); + + other.add(5); + await Future(() {}); + expect(results, [3, 5, 6, 9]); + }); + + test('can combine different typed streams', () async { + var source = StreamController(); + var other = StreamController(); + String times(String a, int b) => a * b; + + var results = []; + unawaited(source.stream + .combineLatest(other.stream, times) + .forEach(results.add)); + + source + ..add('a') + ..add('b'); + await Future(() {}); + expect(results, isEmpty); + + other.add(2); + await Future(() {}); + expect(results, ['bb']); + + other.add(3); + await Future(() {}); + expect(results, ['bb', 'bbb']); + + source.add('c'); + await Future(() {}); + expect(results, ['bb', 'bbb', 'ccc']); + }); + + test('ends after both streams have ended', () async { + var source = StreamController(); + var other = StreamController(); + int sum(int a, int b) => a + b; + + var done = false; + source.stream + .combineLatest(other.stream, sum) + .listen(null, onDone: () => done = true); + + source.add(1); + + await source.close(); + await Future(() {}); + expect(done, false); + + await other.close(); + await Future(() {}); + expect(done, true); + }); + + test('ends if source stream closes without ever emitting a value', + () async { + var source = const Stream.empty(); + var other = StreamController(); + + int sum(int a, int b) => a + b; + + var done = false; + source + .combineLatest(other.stream, sum) + .listen(null, onDone: () => done = true); + + await Future(() {}); + // Nothing can ever be emitted on the result, may as well close. + expect(done, true); + }); + + test('ends if other stream closes without ever emitting a value', () async { + var source = StreamController(); + var other = const Stream.empty(); + + int sum(int a, int b) => a + b; + + var done = false; + source.stream + .combineLatest(other, sum) + .listen(null, onDone: () => done = true); + + await Future(() {}); + // Nothing can ever be emitted on the result, may as well close. + expect(done, true); + }); + + test('forwards errors', () async { + var source = StreamController(); + var other = StreamController(); + int sum(int a, int b) => throw _NumberedException(3); + + var errors = []; + source.stream + .combineLatest(other.stream, sum) + .listen(null, onError: errors.add); + + source.addError(_NumberedException(1)); + other.addError(_NumberedException(2)); + + source.add(1); + other.add(2); + + await Future(() {}); + + expect(errors, [_isException(1), _isException(2), _isException(3)]); + }); + + group('broadcast source', () { + test('can cancel and relisten to broadcast stream', () async { + var source = StreamController.broadcast(); + var other = StreamController(); + int combine(int a, int b) => a + b; + + var emittedValues = []; + var transformed = source.stream.combineLatest(other.stream, combine); + + var subscription = transformed.listen(emittedValues.add); + + source.add(1); + other.add(2); + await Future(() {}); + expect(emittedValues, [3]); + + await subscription.cancel(); + + subscription = transformed.listen(emittedValues.add); + source.add(3); + await Future(() {}); + expect(emittedValues, [3, 5]); + }); + }); + }); +} + +class _NumberedException implements Exception { + final int id; + _NumberedException(this.id); +} + +Matcher _isException(int id) => + const TypeMatcher<_NumberedException>().having((n) => n.id, 'id', id); diff --git a/pkgs/stream_transform/test/concurrent_async_map_test.dart b/pkgs/stream_transform/test/concurrent_async_map_test.dart new file mode 100644 index 000000000..1807f9f8b --- /dev/null +++ b/pkgs/stream_transform/test/concurrent_async_map_test.dart @@ -0,0 +1,157 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + late StreamController controller; + late List emittedValues; + late bool valuesCanceled; + late bool isDone; + late List errors; + late Stream transformed; + late StreamSubscription subscription; + + late List> finishWork; + late List values; + + Future convert(int value) { + values.add(value); + var completer = Completer(); + finishWork.add(completer); + return completer.future; + } + + for (var streamType in streamTypes) { + group('concurrentAsyncMap for stream type: [$streamType]', () { + setUp(() { + valuesCanceled = false; + controller = createController(streamType) + ..onCancel = () { + valuesCanceled = true; + }; + emittedValues = []; + errors = []; + isDone = false; + finishWork = []; + values = []; + transformed = controller.stream.concurrentAsyncMap(convert); + subscription = transformed + .listen(emittedValues.add, onError: errors.add, onDone: () { + isDone = true; + }); + }); + + test('does not emit before convert finishes', () async { + controller.add(1); + await Future(() {}); + expect(emittedValues, isEmpty); + expect(values, [1]); + finishWork.first.complete('result'); + await Future(() {}); + expect(emittedValues, ['result']); + }); + + test('allows calls to convert before the last one finished', () async { + controller + ..add(1) + ..add(2) + ..add(3); + await Future(() {}); + expect(values, [1, 2, 3]); + }); + + test('forwards errors directly without waiting for previous convert', + () async { + controller.add(1); + await Future(() {}); + controller.addError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + + test('forwards errors which occur during the convert', () async { + controller.add(1); + await Future(() {}); + finishWork.first.completeError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + + test('can continue handling events after an error', () async { + controller.add(1); + await Future(() {}); + finishWork[0].completeError('error'); + controller.add(2); + await Future(() {}); + expect(values, [1, 2]); + finishWork[1].completeError('another'); + await Future(() {}); + expect(errors, ['error', 'another']); + }); + + test('cancels value subscription when output canceled', () async { + expect(valuesCanceled, false); + await subscription.cancel(); + expect(valuesCanceled, true); + }); + + test('closes when values end if no conversion is pending', () async { + expect(isDone, false); + await controller.close(); + await Future(() {}); + expect(isDone, true); + }); + + if (streamType == 'broadcast') { + test('multiple listeners all get values', () async { + var otherValues = []; + transformed.listen(otherValues.add); + controller.add(1); + await Future(() {}); + finishWork.first.complete('result'); + await Future(() {}); + expect(emittedValues, ['result']); + expect(otherValues, ['result']); + }); + + test('multiple listeners get done when values end', () async { + var otherDone = false; + transformed.listen(null, onDone: () => otherDone = true); + controller.add(1); + await Future(() {}); + await controller.close(); + expect(isDone, false); + expect(otherDone, false); + finishWork.first.complete(''); + await Future(() {}); + expect(isDone, true); + expect(otherDone, true); + }); + + test('can cancel and relisten', () async { + controller.add(1); + await Future(() {}); + finishWork.first.complete('first'); + await Future(() {}); + await subscription.cancel(); + controller.add(2); + await Future(() {}); + subscription = transformed.listen(emittedValues.add); + controller.add(3); + await Future(() {}); + expect(values, [1, 3]); + finishWork[1].complete('second'); + await Future(() {}); + expect(emittedValues, ['first', 'second']); + }); + } + }); + } +} diff --git a/pkgs/stream_transform/test/debounce_test.dart b/pkgs/stream_transform/test/debounce_test.dart new file mode 100644 index 000000000..19de055c8 --- /dev/null +++ b/pkgs/stream_transform/test/debounce_test.dart @@ -0,0 +1,310 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + for (var streamType in streamTypes) { + group('Stream type [$streamType]', () { + group('debounce - trailing', () { + late StreamController values; + late List emittedValues; + late bool valuesCanceled; + late bool isDone; + late List errors; + late StreamSubscription subscription; + late Stream transformed; + + setUp(() async { + valuesCanceled = false; + values = createController(streamType) + ..onCancel = () { + valuesCanceled = true; + }; + emittedValues = []; + errors = []; + isDone = false; + transformed = values.stream.debounce(const Duration(milliseconds: 5)); + }); + + void listen() { + subscription = transformed + .listen(emittedValues.add, onError: errors.add, onDone: () { + isDone = true; + }); + } + + test('cancels values', () async { + listen(); + await subscription.cancel(); + expect(valuesCanceled, true); + }); + + test('swallows values that come faster than duration', () { + fakeAsync((async) { + listen(); + values + ..add(1) + ..add(2) + ..close(); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [2]); + }); + }); + + test('outputs multiple values spaced further than duration', () { + fakeAsync((async) { + listen(); + values.add(1); + async.elapse(const Duration(milliseconds: 6)); + values.add(2); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [1, 2]); + }); + }); + + test('waits for pending value to close', () { + fakeAsync((async) { + listen(); + values.add(1); + async.elapse(const Duration(milliseconds: 6)); + values.close(); + async.flushMicrotasks(); + expect(isDone, true); + }); + }); + + test('closes output if there are no pending values', () { + fakeAsync((async) { + listen(); + values.add(1); + async.elapse(const Duration(milliseconds: 6)); + values + ..add(2) + ..close(); + async.flushMicrotasks(); + expect(isDone, false); + async.elapse(const Duration(milliseconds: 6)); + expect(isDone, true); + }); + }); + + if (streamType == 'broadcast') { + test('multiple listeners all get values', () { + fakeAsync((async) { + listen(); + var otherValues = []; + transformed.listen(otherValues.add); + values + ..add(1) + ..add(2); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [2]); + expect(otherValues, [2]); + }); + }); + } + }); + + group('debounce - leading', () { + late StreamController values; + late List emittedValues; + late Stream transformed; + late bool isDone; + + setUp(() async { + values = createController(streamType); + emittedValues = []; + isDone = false; + transformed = values.stream.debounce(const Duration(milliseconds: 5), + leading: true, trailing: false); + }); + + void listen() { + transformed.listen(emittedValues.add, onDone: () { + isDone = true; + }); + } + + test('swallows values that come faster than duration', () async { + listen(); + values + ..add(1) + ..add(2); + await values.close(); + expect(emittedValues, [1]); + }); + + test('outputs multiple values spaced further than duration', () { + fakeAsync((async) { + listen(); + values.add(1); + async.elapse(const Duration(milliseconds: 6)); + values.add(2); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [1, 2]); + }); + }); + + if (streamType == 'broadcast') { + test('multiple listeners all get values', () { + fakeAsync((async) { + listen(); + var otherValues = []; + transformed.listen(otherValues.add); + values + ..add(1) + ..add(2); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [1]); + expect(otherValues, [1]); + }); + }); + } + + test('closes output immediately if not waiting for trailing value', + () async { + listen(); + values.add(1); + await values.close(); + expect(isDone, true); + }); + }); + + group('debounce - leading and trailing', () { + late StreamController values; + late List emittedValues; + late Stream transformed; + + setUp(() async { + values = createController(streamType); + emittedValues = []; + transformed = values.stream.debounce(const Duration(milliseconds: 5), + leading: true, trailing: true); + }); + void listen() { + transformed.listen(emittedValues.add); + } + + test('swallows values that come faster than duration', () { + fakeAsync((async) { + listen(); + values + ..add(1) + ..add(2) + ..add(3) + ..close(); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [1, 3]); + }); + }); + + test('outputs multiple values spaced further than duration', () { + fakeAsync((async) { + listen(); + values.add(1); + async.elapse(const Duration(milliseconds: 6)); + values.add(2); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [1, 2]); + }); + }); + + if (streamType == 'broadcast') { + test('multiple listeners all get values', () { + fakeAsync((async) { + listen(); + var otherValues = []; + transformed.listen(otherValues.add); + values + ..add(1) + ..add(2); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [1, 2]); + expect(otherValues, [1, 2]); + }); + }); + } + }); + + group('debounceBuffer', () { + late StreamController values; + late List> emittedValues; + late List errors; + late Stream> transformed; + + setUp(() async { + values = createController(streamType); + emittedValues = []; + errors = []; + transformed = + values.stream.debounceBuffer(const Duration(milliseconds: 5)); + }); + void listen() { + transformed.listen(emittedValues.add, onError: errors.add); + } + + test('Emits all values as a list', () { + fakeAsync((async) { + listen(); + values + ..add(1) + ..add(2) + ..close(); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [ + [1, 2] + ]); + }); + }); + + test('separate lists for multiple values spaced further than duration', + () { + fakeAsync((async) { + listen(); + values.add(1); + async.elapse(const Duration(milliseconds: 6)); + values.add(2); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [ + [1], + [2] + ]); + }); + }); + + if (streamType == 'broadcast') { + test('multiple listeners all get values', () { + fakeAsync((async) { + listen(); + var otherValues = >[]; + transformed.listen(otherValues.add); + values + ..add(1) + ..add(2); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [ + [1, 2] + ]); + expect(otherValues, [ + [1, 2] + ]); + }); + }); + } + }); + }); + } + test('allows nulls', () async { + final values = Stream.fromIterable([null]); + final transformed = values.debounce(const Duration(milliseconds: 1)); + expect(await transformed.toList(), [null]); + }); +} diff --git a/pkgs/stream_transform/test/followd_by_test.dart b/pkgs/stream_transform/test/followd_by_test.dart new file mode 100644 index 000000000..d600d136a --- /dev/null +++ b/pkgs/stream_transform/test/followd_by_test.dart @@ -0,0 +1,159 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + for (var firstType in streamTypes) { + for (var secondType in streamTypes) { + group('followedBy [$firstType] with [$secondType]', () { + late StreamController first; + late StreamController second; + + late List emittedValues; + late bool firstCanceled; + late bool secondCanceled; + late bool secondListened; + late bool isDone; + late List errors; + late Stream transformed; + late StreamSubscription subscription; + + setUp(() async { + firstCanceled = false; + secondCanceled = false; + secondListened = false; + first = createController(firstType) + ..onCancel = () { + firstCanceled = true; + }; + second = createController(secondType) + ..onCancel = () { + secondCanceled = true; + } + ..onListen = () { + secondListened = true; + }; + emittedValues = []; + errors = []; + isDone = false; + transformed = first.stream.followedBy(second.stream); + subscription = transformed + .listen(emittedValues.add, onError: errors.add, onDone: () { + isDone = true; + }); + }); + + test('adds all values from both streams', () async { + first + ..add(1) + ..add(2); + await first.close(); + await Future(() {}); + second + ..add(3) + ..add(4); + await Future(() {}); + expect(emittedValues, [1, 2, 3, 4]); + }); + + test('Does not listen to second stream before first stream finishes', + () async { + expect(secondListened, false); + await first.close(); + expect(secondListened, true); + }); + + test('closes stream after both inputs close', () async { + await first.close(); + await second.close(); + expect(isDone, true); + }); + + test('cancels any type of first stream on cancel', () async { + await subscription.cancel(); + expect(firstCanceled, true); + }); + + if (firstType == 'single subscription') { + test( + 'cancels any type of second stream on cancel if first is ' + 'broadcast', () async { + await first.close(); + await subscription.cancel(); + expect(secondCanceled, true); + }); + + if (secondType == 'broadcast') { + test('can pause and resume during second stream - dropping values', + () async { + await first.close(); + subscription.pause(); + second.add(1); + await Future(() {}); + subscription.resume(); + second.add(2); + await Future(() {}); + expect(emittedValues, [2]); + }); + } else { + test('can pause and resume during second stream - buffering values', + () async { + await first.close(); + subscription.pause(); + second.add(1); + await Future(() {}); + subscription.resume(); + second.add(2); + await Future(() {}); + expect(emittedValues, [1, 2]); + }); + } + } + + if (firstType == 'broadcast') { + test('can cancel and relisten during first stream', () async { + await subscription.cancel(); + first.add(1); + subscription = transformed.listen(emittedValues.add); + first.add(2); + await Future(() {}); + expect(emittedValues, [2]); + }); + + test('can cancel and relisten during second stream', () async { + await first.close(); + await subscription.cancel(); + second.add(2); + await Future(() {}); + subscription = transformed.listen(emittedValues.add); + second.add(3); + await Future(() {}); + expect(emittedValues, [3]); + }); + + test('forwards values to multiple listeners', () async { + var otherValues = []; + transformed.listen(otherValues.add); + first.add(1); + await first.close(); + second.add(2); + await Future(() {}); + var thirdValues = []; + transformed.listen(thirdValues.add); + second.add(3); + await Future(() {}); + expect(emittedValues, [1, 2, 3]); + expect(otherValues, [1, 2, 3]); + expect(thirdValues, [3]); + }); + } + }); + } + } +} diff --git a/pkgs/stream_transform/test/from_handlers_test.dart b/pkgs/stream_transform/test/from_handlers_test.dart new file mode 100644 index 000000000..694199c70 --- /dev/null +++ b/pkgs/stream_transform/test/from_handlers_test.dart @@ -0,0 +1,183 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/src/from_handlers.dart'; +import 'package:test/test.dart'; + +void main() { + late StreamController values; + late List emittedValues; + late bool valuesCanceled; + late bool isDone; + late List errors; + late Stream transformed; + late StreamSubscription subscription; + + void setUpForController(StreamController controller, + Stream Function(Stream) transform) { + valuesCanceled = false; + values = controller + ..onCancel = () { + valuesCanceled = true; + }; + emittedValues = []; + errors = []; + isDone = false; + transformed = transform(values.stream); + subscription = + transformed.listen(emittedValues.add, onError: errors.add, onDone: () { + isDone = true; + }); + } + + group('default from_handlers', () { + group('Single subscription stream', () { + setUp(() { + setUpForController(StreamController(), + (s) => s.transformByHandlers(onData: (e, sink) => sink.add(e))); + }); + + test('has correct stream type', () { + expect(transformed.isBroadcast, false); + }); + + test('forwards values', () async { + values + ..add(1) + ..add(2); + await Future(() {}); + expect(emittedValues, [1, 2]); + }); + + test('forwards errors', () async { + values.addError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + + test('forwards done', () async { + await values.close(); + expect(isDone, true); + }); + + test('forwards cancel', () async { + await subscription.cancel(); + expect(valuesCanceled, true); + }); + }); + + group('broadcast stream with muliple listeners', () { + late List emittedValues2; + late List errors2; + late bool isDone2; + late StreamSubscription subscription2; + + setUp(() { + setUpForController(StreamController.broadcast(), + (s) => s.transformByHandlers(onData: (e, sink) => sink.add(e))); + emittedValues2 = []; + errors2 = []; + isDone2 = false; + subscription2 = transformed + .listen(emittedValues2.add, onError: errors2.add, onDone: () { + isDone2 = true; + }); + }); + + test('has correct stream type', () { + expect(transformed.isBroadcast, true); + }); + + test('forwards values', () async { + values + ..add(1) + ..add(2); + await Future(() {}); + expect(emittedValues, [1, 2]); + expect(emittedValues2, [1, 2]); + }); + + test('forwards errors', () async { + values.addError('error'); + await Future(() {}); + expect(errors, ['error']); + expect(errors2, ['error']); + }); + + test('forwards done', () async { + await values.close(); + expect(isDone, true); + expect(isDone2, true); + }); + + test('forwards cancel', () async { + await subscription.cancel(); + expect(valuesCanceled, false); + await subscription2.cancel(); + expect(valuesCanceled, true); + }); + }); + }); + + group('custom handlers', () { + group('single subscription', () { + setUp(() async { + setUpForController( + StreamController(), + (s) => s.transformByHandlers(onData: (value, sink) { + sink.add(value + 1); + })); + }); + test('uses transform from handleData', () async { + values + ..add(1) + ..add(2); + await Future(() {}); + expect(emittedValues, [2, 3]); + }); + }); + + group('broadcast stream with multiple listeners', () { + late int dataCallCount; + late int doneCallCount; + late int errorCallCount; + + setUp(() async { + dataCallCount = 0; + doneCallCount = 0; + errorCallCount = 0; + setUpForController( + StreamController.broadcast(), + (s) => s.transformByHandlers(onData: (value, sink) { + dataCallCount++; + }, onError: (error, stackTrace, sink) { + errorCallCount++; + sink.addError(error, stackTrace); + }, onDone: (sink) { + doneCallCount++; + })); + transformed.listen((_) {}, onError: (_, __) {}); + }); + + test('handles data once', () async { + values.add(1); + await Future(() {}); + expect(dataCallCount, 1); + }); + + test('handles done once', () async { + await values.close(); + expect(doneCallCount, 1); + }); + + test('handles errors once', () async { + values.addError('error'); + await Future(() {}); + expect(errorCallCount, 1); + }); + }); + }); +} diff --git a/pkgs/stream_transform/test/merge_test.dart b/pkgs/stream_transform/test/merge_test.dart new file mode 100644 index 000000000..ecbf97f26 --- /dev/null +++ b/pkgs/stream_transform/test/merge_test.dart @@ -0,0 +1,140 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +void main() { + group('merge', () { + test('includes all values', () async { + var first = Stream.fromIterable([1, 2, 3]); + var second = Stream.fromIterable([4, 5, 6]); + var allValues = await first.merge(second).toList(); + expect(allValues, containsAllInOrder([1, 2, 3])); + expect(allValues, containsAllInOrder([4, 5, 6])); + expect(allValues, hasLength(6)); + }); + + test('cancels both sources', () async { + var firstCanceled = false; + var first = StreamController() + ..onCancel = () { + firstCanceled = true; + }; + var secondCanceled = false; + var second = StreamController() + ..onCancel = () { + secondCanceled = true; + }; + var subscription = first.stream.merge(second.stream).listen((_) {}); + await subscription.cancel(); + expect(firstCanceled, true); + expect(secondCanceled, true); + }); + + test('completes when both sources complete', () async { + var first = StreamController(); + var second = StreamController(); + var isDone = false; + first.stream.merge(second.stream).listen((_) {}, onDone: () { + isDone = true; + }); + await first.close(); + expect(isDone, false); + await second.close(); + expect(isDone, true); + }); + + test('can cancel and relisten to broadcast stream', () async { + var first = StreamController.broadcast(); + var second = StreamController(); + var emittedValues = []; + var transformed = first.stream.merge(second.stream); + var subscription = transformed.listen(emittedValues.add); + first.add(1); + second.add(2); + await Future(() {}); + expect(emittedValues, contains(1)); + expect(emittedValues, contains(2)); + await subscription.cancel(); + emittedValues = []; + subscription = transformed.listen(emittedValues.add); + first.add(3); + second.add(4); + await Future(() {}); + expect(emittedValues, contains(3)); + expect(emittedValues, contains(4)); + }); + }); + + group('mergeAll', () { + test('includes all values', () async { + var first = Stream.fromIterable([1, 2, 3]); + var second = Stream.fromIterable([4, 5, 6]); + var third = Stream.fromIterable([7, 8, 9]); + var allValues = await first.mergeAll([second, third]).toList(); + expect(allValues, containsAllInOrder([1, 2, 3])); + expect(allValues, containsAllInOrder([4, 5, 6])); + expect(allValues, containsAllInOrder([7, 8, 9])); + expect(allValues, hasLength(9)); + }); + + test('handles mix of broadcast and single-subscription', () async { + var firstCanceled = false; + var first = StreamController.broadcast() + ..onCancel = () { + firstCanceled = true; + }; + var secondBroadcastCanceled = false; + var secondBroadcast = StreamController.broadcast() + ..onCancel = () { + secondBroadcastCanceled = true; + }; + var secondSingleCanceled = false; + var secondSingle = StreamController() + ..onCancel = () { + secondSingleCanceled = true; + }; + + var merged = + first.stream.mergeAll([secondBroadcast.stream, secondSingle.stream]); + + var firstListenerValues = []; + var secondListenerValues = []; + + var firstSubscription = merged.listen(firstListenerValues.add); + var secondSubscription = merged.listen(secondListenerValues.add); + + first.add(1); + secondBroadcast.add(2); + secondSingle.add(3); + + await Future(() {}); + await firstSubscription.cancel(); + + expect(firstCanceled, false); + expect(secondBroadcastCanceled, false); + expect(secondSingleCanceled, false); + + first.add(4); + secondBroadcast.add(5); + secondSingle.add(6); + + await Future(() {}); + await secondSubscription.cancel(); + + await Future(() {}); + expect(firstCanceled, true); + expect(secondBroadcastCanceled, true); + expect(secondSingleCanceled, false, + reason: 'Single subscription streams merged into broadcast streams ' + 'are not canceled'); + + expect(firstListenerValues, [1, 2, 3]); + expect(secondListenerValues, [1, 2, 3, 4, 5, 6]); + }); + }); +} diff --git a/pkgs/stream_transform/test/sample_test.dart b/pkgs/stream_transform/test/sample_test.dart new file mode 100644 index 000000000..66ca09db8 --- /dev/null +++ b/pkgs/stream_transform/test/sample_test.dart @@ -0,0 +1,291 @@ +// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + late StreamController trigger; + late StreamController values; + late List emittedValues; + late bool valuesCanceled; + late bool triggerCanceled; + late bool triggerPaused; + late bool isDone; + late List errors; + late Stream transformed; + late StreamSubscription subscription; + + void setUpForStreamTypes(String triggerType, String valuesType, + {required bool longPoll}) { + valuesCanceled = false; + triggerCanceled = false; + triggerPaused = false; + trigger = createController(triggerType) + ..onCancel = () { + triggerCanceled = true; + }; + if (triggerType == 'single subscription') { + trigger.onPause = () { + triggerPaused = true; + }; + } + values = createController(valuesType) + ..onCancel = () { + valuesCanceled = true; + }; + emittedValues = []; + errors = []; + isDone = false; + transformed = values.stream.sample(trigger.stream, longPoll: longPoll); + subscription = + transformed.listen(emittedValues.add, onError: errors.add, onDone: () { + isDone = true; + }); + } + + for (var triggerType in streamTypes) { + for (var valuesType in streamTypes) { + group('Trigger type: [$triggerType], Values type: [$valuesType]', () { + group('general behavior', () { + setUp(() { + setUpForStreamTypes(triggerType, valuesType, longPoll: true); + }); + + test('does not emit before `trigger`', () async { + values.add(1); + await Future(() {}); + expect(emittedValues, isEmpty); + trigger.add(null); + await Future(() {}); + expect(emittedValues, [1]); + }); + + test('keeps most recent event between triggers', () async { + values + ..add(1) + ..add(2); + await Future(() {}); + trigger.add(null); + values + ..add(3) + ..add(4); + await Future(() {}); + trigger.add(null); + await Future(() {}); + expect(emittedValues, [2, 4]); + }); + + test('cancels value subscription when output canceled', () async { + expect(valuesCanceled, false); + await subscription.cancel(); + expect(valuesCanceled, true); + }); + + test('closes when trigger ends', () async { + expect(isDone, false); + await trigger.close(); + await Future(() {}); + expect(isDone, true); + }); + + test('closes after outputting final values when source closes', + () async { + expect(isDone, false); + values.add(1); + await values.close(); + expect(isDone, false); + trigger.add(null); + await Future(() {}); + expect(emittedValues, [1]); + expect(isDone, true); + }); + + test('closes when source closes and there is no pending', () async { + expect(isDone, false); + await values.close(); + await Future(() {}); + expect(isDone, true); + }); + + test('forwards errors from trigger', () async { + trigger.addError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + + test('forwards errors from values', () async { + values.addError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + }); + + group('long polling', () { + setUp(() { + setUpForStreamTypes(triggerType, valuesType, longPoll: true); + }); + + test('emits immediately if trigger emits before a value', () async { + trigger.add(null); + await Future(() {}); + expect(emittedValues, isEmpty); + values.add(1); + await Future(() {}); + expect(emittedValues, [1]); + }); + + test('two triggers in a row - emit buffere then emit next value', + () async { + values + ..add(1) + ..add(2); + await Future(() {}); + trigger + ..add(null) + ..add(null); + await Future(() {}); + values.add(3); + await Future(() {}); + expect(emittedValues, [2, 3]); + }); + + test('pre-emptive trigger then trigger after values', () async { + trigger.add(null); + await Future(() {}); + values + ..add(1) + ..add(2); + await Future(() {}); + trigger.add(null); + await Future(() {}); + expect(emittedValues, [1, 2]); + }); + + test('multiple pre-emptive triggers, only emits first value', + () async { + trigger + ..add(null) + ..add(null); + await Future(() {}); + values + ..add(1) + ..add(2); + await Future(() {}); + expect(emittedValues, [1]); + }); + + test('closes if there is no waiting long poll when source closes', + () async { + expect(isDone, false); + values.add(1); + trigger.add(null); + await values.close(); + await Future(() {}); + expect(isDone, true); + }); + + test('waits to emit if there waiting long poll when trigger closes', + () async { + trigger.add(null); + await trigger.close(); + expect(isDone, false); + values.add(1); + await Future(() {}); + expect(emittedValues, [1]); + expect(isDone, true); + }); + }); + + group('immediate polling', () { + setUp(() { + setUpForStreamTypes(triggerType, valuesType, longPoll: false); + }); + + test('ignores trigger before values', () async { + trigger.add(null); + await Future(() {}); + values + ..add(1) + ..add(2); + await Future(() {}); + trigger.add(null); + await Future(() {}); + expect(emittedValues, [2]); + }); + + test('ignores trigger if no pending values', () async { + values + ..add(1) + ..add(2); + await Future(() {}); + trigger + ..add(null) + ..add(null); + await Future(() {}); + values + ..add(3) + ..add(4); + await Future(() {}); + trigger.add(null); + await Future(() {}); + expect(emittedValues, [2, 4]); + }); + }); + }); + } + } + + test('always cancels trigger if values is singlesubscription', () async { + setUpForStreamTypes('broadcast', 'single subscription', longPoll: true); + expect(triggerCanceled, false); + await subscription.cancel(); + expect(triggerCanceled, true); + + setUpForStreamTypes('single subscription', 'single subscription', + longPoll: true); + expect(triggerCanceled, false); + await subscription.cancel(); + expect(triggerCanceled, true); + }); + + test('cancels trigger if trigger is broadcast', () async { + setUpForStreamTypes('broadcast', 'broadcast', longPoll: true); + expect(triggerCanceled, false); + await subscription.cancel(); + expect(triggerCanceled, true); + }); + + test('pauses single subscription trigger for broadcast values', () async { + setUpForStreamTypes('single subscription', 'broadcast', longPoll: true); + expect(triggerCanceled, false); + expect(triggerPaused, false); + await subscription.cancel(); + expect(triggerCanceled, false); + expect(triggerPaused, true); + }); + + for (var triggerType in streamTypes) { + test('cancel and relisten with [$triggerType] trigger', () async { + setUpForStreamTypes(triggerType, 'broadcast', longPoll: true); + values.add(1); + trigger.add(null); + await Future(() {}); + expect(emittedValues, [1]); + await subscription.cancel(); + values.add(2); + trigger.add(null); + await Future(() {}); + subscription = transformed.listen(emittedValues.add); + values.add(3); + trigger.add(null); + await Future(() {}); + expect(emittedValues, [1, 3]); + }); + } +} diff --git a/pkgs/stream_transform/test/scan_test.dart b/pkgs/stream_transform/test/scan_test.dart new file mode 100644 index 000000000..3c749e7a7 --- /dev/null +++ b/pkgs/stream_transform/test/scan_test.dart @@ -0,0 +1,109 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +void main() { + group('Scan', () { + test('produces intermediate values', () async { + var source = Stream.fromIterable([1, 2, 3, 4]); + int sum(int x, int y) => x + y; + var result = await source.scan(0, sum).toList(); + + expect(result, [1, 3, 6, 10]); + }); + + test('can create a broadcast stream', () { + var source = StreamController.broadcast(); + + var transformed = source.stream.scan(null, (_, __) {}); + + expect(transformed.isBroadcast, true); + }); + + test('forwards errors from source', () async { + var source = StreamController(); + + int sum(int x, int y) => x + y; + + var errors = []; + + source.stream.scan(0, sum).listen(null, onError: errors.add); + + source.addError(StateError('fail')); + await Future(() {}); + + expect(errors, [isStateError]); + }); + + group('with async combine', () { + test('returns a Stream of non-futures', () async { + var source = Stream.fromIterable([1, 2, 3, 4]); + Future sum(int x, int y) async => x + y; + var result = await source.scan(0, sum).toList(); + + expect(result, [1, 3, 6, 10]); + }); + + test('can return a Stream of futures when specified', () async { + var source = Stream.fromIterable([1, 2]); + Future sum(Future x, int y) async => (await x) + y; + var result = + await source.scan>(Future.value(0), sum).toList(); + + expect(result, [ + const TypeMatcher>(), + const TypeMatcher>() + ]); + expect(await result.wait, [1, 3]); + }); + + test('does not call for subsequent values while waiting', () async { + var source = StreamController(); + + var calledWith = []; + var block = Completer(); + Future combine(int x, int y) async { + calledWith.add(y); + await block.future; + return x + y; + } + + var results = []; + + unawaited(source.stream.scan(0, combine).forEach(results.add)); + + source + ..add(1) + ..add(2); + await Future(() {}); + expect(calledWith, [1]); + expect(results, isEmpty); + + block.complete(); + await Future(() {}); + expect(calledWith, [1, 2]); + expect(results, [1, 3]); + }); + + test('forwards async errors', () async { + var source = StreamController(); + + Future combine(int x, int y) async => throw StateError('fail'); + + var errors = []; + + source.stream.scan(0, combine).listen(null, onError: errors.add); + + source.add(1); + await Future(() {}); + + expect(errors, [isStateError]); + }); + }); + }); +} diff --git a/pkgs/stream_transform/test/start_with_test.dart b/pkgs/stream_transform/test/start_with_test.dart new file mode 100644 index 000000000..35f03308e --- /dev/null +++ b/pkgs/stream_transform/test/start_with_test.dart @@ -0,0 +1,167 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + late StreamController values; + late Stream transformed; + late StreamSubscription subscription; + + late List emittedValues; + late bool isDone; + + void setupForStreamType( + String streamType, Stream Function(Stream) transform) { + emittedValues = []; + isDone = false; + values = createController(streamType); + transformed = transform(values.stream); + subscription = + transformed.listen(emittedValues.add, onDone: () => isDone = true); + } + + for (var streamType in streamTypes) { + group('startWith then [$streamType]', () { + setUp(() => setupForStreamType(streamType, (s) => s.startWith(1))); + + test('outputs all values', () async { + values + ..add(2) + ..add(3); + await Future(() {}); + expect(emittedValues, [1, 2, 3]); + }); + + test('outputs initial when followed by empty stream', () async { + await values.close(); + expect(emittedValues, [1]); + }); + + test('closes with values', () async { + expect(isDone, false); + await values.close(); + expect(isDone, true); + }); + + if (streamType == 'broadcast') { + test('can cancel and relisten', () async { + values.add(2); + await Future(() {}); + await subscription.cancel(); + subscription = transformed.listen(emittedValues.add); + values.add(3); + await Future(() {}); + await Future(() {}); + expect(emittedValues, [1, 2, 3]); + }); + } + }); + + group('startWithMany then [$streamType]', () { + setUp(() async { + setupForStreamType(streamType, (s) => s.startWithMany([1, 2])); + // Ensure all initial values go through + await Future(() {}); + }); + + test('outputs all values', () async { + values + ..add(3) + ..add(4); + await Future(() {}); + expect(emittedValues, [1, 2, 3, 4]); + }); + + test('outputs initial when followed by empty stream', () async { + await values.close(); + expect(emittedValues, [1, 2]); + }); + + test('closes with values', () async { + expect(isDone, false); + await values.close(); + expect(isDone, true); + }); + + if (streamType == 'broadcast') { + test('can cancel and relisten', () async { + values.add(3); + await Future(() {}); + await subscription.cancel(); + subscription = transformed.listen(emittedValues.add); + values.add(4); + await Future(() {}); + expect(emittedValues, [1, 2, 3, 4]); + }); + } + }); + + for (var startingStreamType in streamTypes) { + group('startWithStream [$startingStreamType] then [$streamType]', () { + late StreamController starting; + setUp(() async { + starting = createController(startingStreamType); + setupForStreamType( + streamType, (s) => s.startWithStream(starting.stream)); + }); + + test('outputs all values', () async { + starting + ..add(1) + ..add(2); + await starting.close(); + values + ..add(3) + ..add(4); + await Future(() {}); + expect(emittedValues, [1, 2, 3, 4]); + }); + + test('closes with values', () async { + expect(isDone, false); + await starting.close(); + expect(isDone, false); + await values.close(); + expect(isDone, true); + }); + + if (streamType == 'broadcast') { + test('can cancel and relisten during starting', () async { + starting.add(1); + await Future(() {}); + await subscription.cancel(); + subscription = transformed.listen(emittedValues.add); + starting.add(2); + await starting.close(); + values + ..add(3) + ..add(4); + await Future(() {}); + expect(emittedValues, [1, 2, 3, 4]); + }); + + test('can cancel and relisten during values', () async { + starting + ..add(1) + ..add(2); + await starting.close(); + values.add(3); + await Future(() {}); + await subscription.cancel(); + subscription = transformed.listen(emittedValues.add); + values.add(4); + await Future(() {}); + expect(emittedValues, [1, 2, 3, 4]); + }); + } + }); + } + } +} diff --git a/pkgs/stream_transform/test/switch_test.dart b/pkgs/stream_transform/test/switch_test.dart new file mode 100644 index 000000000..9e70c0811 --- /dev/null +++ b/pkgs/stream_transform/test/switch_test.dart @@ -0,0 +1,229 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + for (var outerType in streamTypes) { + for (var innerType in streamTypes) { + group('Outer type: [$outerType], Inner type: [$innerType]', () { + late StreamController first; + late StreamController second; + late StreamController third; + late StreamController> outer; + + late List emittedValues; + late bool firstCanceled; + late bool outerCanceled; + late bool isDone; + late List errors; + late StreamSubscription subscription; + + setUp(() async { + firstCanceled = false; + outerCanceled = false; + outer = createController(outerType) + ..onCancel = () { + outerCanceled = true; + }; + first = createController(innerType) + ..onCancel = () { + firstCanceled = true; + }; + second = createController(innerType); + third = createController(innerType); + emittedValues = []; + errors = []; + isDone = false; + subscription = outer.stream + .switchLatest() + .listen(emittedValues.add, onError: errors.add, onDone: () { + isDone = true; + }); + }); + + test('forwards events', () async { + outer.add(first.stream); + await Future(() {}); + first + ..add(1) + ..add(2); + await Future(() {}); + + outer.add(second.stream); + await Future(() {}); + second + ..add(3) + ..add(4); + await Future(() {}); + + expect(emittedValues, [1, 2, 3, 4]); + }); + + test('forwards errors from outer Stream', () async { + outer.addError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + + test('forwards errors from inner Stream', () async { + outer.add(first.stream); + await Future(() {}); + first.addError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + + test('closes when final stream is done', () async { + outer.add(first.stream); + await Future(() {}); + + outer.add(second.stream); + await Future(() {}); + + await outer.close(); + expect(isDone, false); + + await second.close(); + expect(isDone, true); + }); + + test( + 'closes when outer stream closes if latest inner stream already ' + 'closed', () async { + outer.add(first.stream); + await Future(() {}); + await first.close(); + expect(isDone, false); + + await outer.close(); + expect(isDone, true); + }); + + test('cancels listeners on previous streams', () async { + outer.add(first.stream); + await Future(() {}); + + outer.add(second.stream); + await Future(() {}); + expect(firstCanceled, true); + }); + + if (innerType != 'broadcast') { + test('waits for cancel before listening to subsequent stream', + () async { + var cancelWork = Completer(); + first.onCancel = () => cancelWork.future; + outer.add(first.stream); + await Future(() {}); + + var cancelDone = false; + second.onListen = expectAsync0(() { + expect(cancelDone, true); + }); + outer.add(second.stream); + await Future(() {}); + cancelWork.complete(); + cancelDone = true; + }); + + test('all streams are listened to, even while cancelling', () async { + var cancelWork = Completer(); + first.onCancel = () => cancelWork.future; + outer.add(first.stream); + await Future(() {}); + + var cancelDone = false; + second.onListen = expectAsync0(() { + expect(cancelDone, true); + }); + third.onListen = expectAsync0(() { + expect(cancelDone, true); + }); + outer + ..add(second.stream) + ..add(third.stream); + await Future(() {}); + cancelWork.complete(); + cancelDone = true; + }); + } + + if (outerType != 'broadcast' && innerType != 'broadcast') { + test('pausing while cancelling an inner stream is respected', + () async { + var cancelWork = Completer(); + first.onCancel = () => cancelWork.future; + outer.add(first.stream); + await Future(() {}); + + var cancelDone = false; + second.onListen = expectAsync0(() { + expect(cancelDone, true); + }); + outer.add(second.stream); + await Future(() {}); + subscription.pause(); + cancelWork.complete(); + cancelDone = true; + await Future(() {}); + expect(second.isPaused, true); + subscription.resume(); + }); + } + + test('cancels listener on current and outer stream on cancel', + () async { + outer.add(first.stream); + await Future(() {}); + await subscription.cancel(); + + await Future(() {}); + expect(outerCanceled, true); + expect(firstCanceled, true); + }); + }); + } + } + + group('switchMap', () { + test('uses map function', () async { + var outer = StreamController>(); + + var values = []; + outer.stream.switchMap(Stream.fromIterable).listen(values.add); + + outer.add([1, 2, 3]); + await Future(() {}); + outer.add([4, 5, 6]); + await Future(() {}); + expect(values, [1, 2, 3, 4, 5, 6]); + }); + + test('can create a broadcast stream', () async { + var outer = StreamController.broadcast(); + + var transformed = + outer.stream.switchMap((_) => const Stream.empty()); + + expect(transformed.isBroadcast, true); + }); + + test('forwards errors from the convert callback', () async { + var errors = []; + var source = Stream.fromIterable([1, 2, 3]); + source.switchMap((i) { + // ignore: only_throw_errors + throw 'Error: $i'; + }).listen((_) {}, onError: errors.add); + await Future(() {}); + expect(errors, ['Error: 1', 'Error: 2', 'Error: 3']); + }); + }); +} diff --git a/pkgs/stream_transform/test/take_until_test.dart b/pkgs/stream_transform/test/take_until_test.dart new file mode 100644 index 000000000..982b3daac --- /dev/null +++ b/pkgs/stream_transform/test/take_until_test.dart @@ -0,0 +1,135 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + for (var streamType in streamTypes) { + group('takeUntil on Stream type [$streamType]', () { + late StreamController values; + late List emittedValues; + late bool valuesCanceled; + late bool isDone; + late List errors; + late Stream transformed; + late StreamSubscription subscription; + late Completer closeTrigger; + + setUp(() { + valuesCanceled = false; + values = createController(streamType) + ..onCancel = () { + valuesCanceled = true; + }; + emittedValues = []; + errors = []; + isDone = false; + closeTrigger = Completer(); + transformed = values.stream.takeUntil(closeTrigger.future); + subscription = transformed + .listen(emittedValues.add, onError: errors.add, onDone: () { + isDone = true; + }); + }); + + test('forwards cancellation', () async { + await subscription.cancel(); + expect(valuesCanceled, true); + }); + + test('lets values through before trigger', () async { + values + ..add(1) + ..add(2); + await Future(() {}); + expect(emittedValues, [1, 2]); + }); + + test('forwards errors', () async { + values.addError('error'); + await Future(() {}); + expect(errors, ['error']); + }); + + test('sends done if original strem ends', () async { + await values.close(); + expect(isDone, true); + }); + + test('sends done when trigger fires', () async { + closeTrigger.complete(); + await Future(() {}); + expect(isDone, true); + }); + + test('forwards errors from the close trigger', () async { + closeTrigger.completeError('sad'); + await Future(() {}); + expect(errors, ['sad']); + expect(isDone, true); + }); + + test('ignores errors from the close trigger after stream closed', + () async { + await values.close(); + closeTrigger.completeError('sad'); + await Future(() {}); + expect(errors, []); + }); + + test('cancels value subscription when trigger fires', () async { + closeTrigger.complete(); + await Future(() {}); + expect(valuesCanceled, true); + }); + + if (streamType == 'broadcast') { + test('multiple listeners all get values', () async { + var otherValues = []; + transformed.listen(otherValues.add); + values + ..add(1) + ..add(2); + await Future(() {}); + expect(emittedValues, [1, 2]); + expect(otherValues, [1, 2]); + }); + + test('multiple listeners get done when trigger fires', () async { + var otherDone = false; + transformed.listen(null, onDone: () => otherDone = true); + closeTrigger.complete(); + await Future(() {}); + expect(otherDone, true); + expect(isDone, true); + }); + + test('multiple listeners get done when values end', () async { + var otherDone = false; + transformed.listen(null, onDone: () => otherDone = true); + await values.close(); + expect(otherDone, true); + expect(isDone, true); + }); + + test('can cancel and relisten before trigger fires', () async { + values.add(1); + await Future(() {}); + await subscription.cancel(); + values.add(2); + await Future(() {}); + subscription = transformed.listen(emittedValues.add); + values.add(3); + await Future(() {}); + expect(emittedValues, [1, 3]); + }); + } + }); + } +} diff --git a/pkgs/stream_transform/test/tap_test.dart b/pkgs/stream_transform/test/tap_test.dart new file mode 100644 index 000000000..f2b43464e --- /dev/null +++ b/pkgs/stream_transform/test/tap_test.dart @@ -0,0 +1,116 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +void main() { + test('calls function for values', () async { + var valuesSeen = []; + var stream = Stream.fromIterable([1, 2, 3]); + await stream.tap(valuesSeen.add).last; + expect(valuesSeen, [1, 2, 3]); + }); + + test('forwards values', () async { + var stream = Stream.fromIterable([1, 2, 3]); + var values = await stream.tap((_) {}).toList(); + expect(values, [1, 2, 3]); + }); + + test('calls function for errors', () async { + dynamic error; + var source = StreamController(); + source.stream.tap((_) {}, onError: (e, st) { + error = e; + }).listen((_) {}, onError: (_) {}); + source.addError('error'); + await Future(() {}); + expect(error, 'error'); + }); + + test('forwards errors', () async { + dynamic error; + var source = StreamController(); + source.stream.tap((_) {}, onError: (e, st) {}).listen((_) {}, + onError: (Object e) { + error = e; + }); + source.addError('error'); + await Future(() {}); + expect(error, 'error'); + }); + + test('calls function on done', () async { + var doneCalled = false; + var source = StreamController(); + source.stream.tap((_) {}, onDone: () { + doneCalled = true; + }).listen((_) {}); + await source.close(); + expect(doneCalled, true); + }); + + test('forwards only once with multiple listeners on a broadcast stream', + () async { + var dataCallCount = 0; + var source = StreamController.broadcast(); + source.stream.tap((_) { + dataCallCount++; + }) + ..listen((_) {}) + ..listen((_) {}); + source.add(1); + await Future(() {}); + expect(dataCallCount, 1); + }); + + test( + 'forwards errors only once with multiple listeners on a broadcast stream', + () async { + var errorCallCount = 0; + var source = StreamController.broadcast(); + source.stream.tap((_) {}, onError: (_, __) { + errorCallCount++; + }) + ..listen((_) {}, onError: (_, __) {}) + ..listen((_) {}, onError: (_, __) {}); + source.addError('error'); + await Future(() {}); + expect(errorCallCount, 1); + }); + + test('calls onDone only once with multiple listeners on a broadcast stream', + () async { + var doneCallCount = 0; + var source = StreamController.broadcast(); + source.stream.tap((_) {}, onDone: () { + doneCallCount++; + }) + ..listen((_) {}) + ..listen((_) {}); + await source.close(); + expect(doneCallCount, 1); + }); + + test('forwards values to multiple listeners', () async { + var source = StreamController.broadcast(); + var emittedValues1 = []; + var emittedValues2 = []; + source.stream.tap((_) {}) + ..listen(emittedValues1.add) + ..listen(emittedValues2.add); + source.add(1); + await Future(() {}); + expect(emittedValues1, [1]); + expect(emittedValues2, [1]); + }); + + test('allows null callback', () async { + var stream = Stream.fromIterable([1, 2, 3]); + await stream.tap(null).last; + }); +} diff --git a/pkgs/stream_transform/test/throttle_test.dart b/pkgs/stream_transform/test/throttle_test.dart new file mode 100644 index 000000000..07f607a99 --- /dev/null +++ b/pkgs/stream_transform/test/throttle_test.dart @@ -0,0 +1,193 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + for (var streamType in streamTypes) { + group('Stream type [$streamType]', () { + late StreamController values; + late List emittedValues; + late bool valuesCanceled; + late bool isDone; + late Stream transformed; + late StreamSubscription subscription; + + group('throttle - trailing: false', () { + setUp(() async { + valuesCanceled = false; + values = createController(streamType) + ..onCancel = () { + valuesCanceled = true; + }; + emittedValues = []; + isDone = false; + transformed = values.stream.throttle(const Duration(milliseconds: 5)); + }); + + void listen() { + subscription = transformed.listen(emittedValues.add, onDone: () { + isDone = true; + }); + } + + test('cancels values', () async { + listen(); + await subscription.cancel(); + expect(valuesCanceled, true); + }); + + test('swallows values that come faster than duration', () { + fakeAsync((async) { + listen(); + values + ..add(1) + ..add(2) + ..close(); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [1]); + }); + }); + + test('outputs multiple values spaced further than duration', () { + fakeAsync((async) { + listen(); + values.add(1); + async.elapse(const Duration(milliseconds: 6)); + values.add(2); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [1, 2]); + async.elapse(const Duration(milliseconds: 6)); + }); + }); + + test('closes output immediately', () { + fakeAsync((async) { + listen(); + values.add(1); + async.elapse(const Duration(milliseconds: 6)); + values + ..add(2) + ..close(); + async.flushMicrotasks(); + expect(isDone, true); + }); + }); + + if (streamType == 'broadcast') { + test('multiple listeners all get values', () { + fakeAsync((async) { + listen(); + var otherValues = []; + transformed.listen(otherValues.add); + values.add(1); + async.flushMicrotasks(); + expect(emittedValues, [1]); + expect(otherValues, [1]); + }); + }); + } + }); + + group('throttle - trailing: true', () { + setUp(() async { + valuesCanceled = false; + values = createController(streamType) + ..onCancel = () { + valuesCanceled = true; + }; + emittedValues = []; + isDone = false; + transformed = values.stream + .throttle(const Duration(milliseconds: 5), trailing: true); + }); + void listen() { + subscription = transformed.listen(emittedValues.add, onDone: () { + isDone = true; + }); + } + + test('emits both first and last in a period', () { + fakeAsync((async) { + listen(); + values + ..add(1) + ..add(2) + ..close(); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [1, 2]); + }); + }); + + test('swallows values that are not the latest in a period', () { + fakeAsync((async) { + listen(); + values + ..add(1) + ..add(2) + ..add(3) + ..close(); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [1, 3]); + }); + }); + + test('waits to output the last value even if the stream closes', + () async { + fakeAsync((async) { + listen(); + values + ..add(1) + ..add(2) + ..close(); + async.flushMicrotasks(); + expect(isDone, false); + expect(emittedValues, [1], + reason: 'Should not be emitted until after duration'); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [1, 2]); + expect(isDone, true); + async.elapse(const Duration(milliseconds: 6)); + }); + }); + + test('closes immediately if there is no pending value', () { + fakeAsync((async) { + listen(); + values + ..add(1) + ..close(); + async.flushMicrotasks(); + expect(isDone, true); + }); + }); + + if (streamType == 'broadcast') { + test('multiple listeners all get values', () { + fakeAsync((async) { + listen(); + var otherValues = []; + transformed.listen(otherValues.add); + values + ..add(1) + ..add(2); + async.flushMicrotasks(); + expect(emittedValues, [1]); + expect(otherValues, [1]); + async.elapse(const Duration(milliseconds: 6)); + expect(emittedValues, [1, 2]); + expect(otherValues, [1, 2]); + }); + }); + } + }); + }); + } +} diff --git a/pkgs/stream_transform/test/utils.dart b/pkgs/stream_transform/test/utils.dart new file mode 100644 index 000000000..42d9613ba --- /dev/null +++ b/pkgs/stream_transform/test/utils.dart @@ -0,0 +1,19 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +StreamController createController(String streamType) { + switch (streamType) { + case 'single subscription': + return StreamController(); + case 'broadcast': + return StreamController.broadcast(); + default: + throw ArgumentError.value( + streamType, 'streamType', 'Must be one of $streamTypes'); + } +} + +const streamTypes = ['single subscription', 'broadcast']; diff --git a/pkgs/stream_transform/test/where_not_null_test.dart b/pkgs/stream_transform/test/where_not_null_test.dart new file mode 100644 index 000000000..c9af79473 --- /dev/null +++ b/pkgs/stream_transform/test/where_not_null_test.dart @@ -0,0 +1,56 @@ +// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +void main() { + test('forwards only events that match the type', () async { + var values = Stream.fromIterable([null, 'a', null, 'b']); + var filtered = values.whereNotNull(); + expect(await filtered.toList(), ['a', 'b']); + }); + + test('can result in empty stream', () async { + var values = Stream.fromIterable([null, null]); + var filtered = values.whereNotNull(); + expect(await filtered.isEmpty, true); + }); + + test('forwards values to multiple listeners', () async { + var values = StreamController.broadcast(); + var filtered = values.stream.whereNotNull(); + var firstValues = []; + var secondValues = []; + filtered + ..listen(firstValues.add) + ..listen(secondValues.add); + values + ..add(null) + ..add('a') + ..add(null) + ..add('b'); + await Future(() {}); + expect(firstValues, ['a', 'b']); + expect(secondValues, ['a', 'b']); + }); + + test('closes streams with multiple listeners', () async { + var values = StreamController.broadcast(); + var filtered = values.stream.whereNotNull(); + var firstDone = false; + var secondDone = false; + filtered + ..listen(null, onDone: () => firstDone = true) + ..listen(null, onDone: () => secondDone = true); + values + ..add(null) + ..add('a'); + await values.close(); + expect(firstDone, true); + expect(secondDone, true); + }); +} diff --git a/pkgs/stream_transform/test/where_type_test.dart b/pkgs/stream_transform/test/where_type_test.dart new file mode 100644 index 000000000..4cbea3770 --- /dev/null +++ b/pkgs/stream_transform/test/where_type_test.dart @@ -0,0 +1,56 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +void main() { + test('forwards only events that match the type', () async { + var values = Stream.fromIterable([1, 'a', 2, 'b']); + var filtered = values.whereType(); + expect(await filtered.toList(), ['a', 'b']); + }); + + test('can result in empty stream', () async { + var values = Stream.fromIterable([1, 2, 3, 4]); + var filtered = values.whereType(); + expect(await filtered.isEmpty, true); + }); + + test('forwards values to multiple listeners', () async { + var values = StreamController.broadcast(); + var filtered = values.stream.whereType(); + var firstValues = []; + var secondValues = []; + filtered + ..listen(firstValues.add) + ..listen(secondValues.add); + values + ..add(1) + ..add('a') + ..add(2) + ..add('b'); + await Future(() {}); + expect(firstValues, ['a', 'b']); + expect(secondValues, ['a', 'b']); + }); + + test('closes streams with multiple listeners', () async { + var values = StreamController.broadcast(); + var filtered = values.stream.whereType(); + var firstDone = false; + var secondDone = false; + filtered + ..listen(null, onDone: () => firstDone = true) + ..listen(null, onDone: () => secondDone = true); + values + ..add(1) + ..add('a'); + await values.close(); + expect(firstDone, true); + expect(secondDone, true); + }); +} diff --git a/pkgs/string_scanner/.github/dependabot.yml b/pkgs/string_scanner/.github/dependabot.yml new file mode 100644 index 000000000..a19a66adf --- /dev/null +++ b/pkgs/string_scanner/.github/dependabot.yml @@ -0,0 +1,16 @@ +# Set update schedule for GitHub Actions +# See https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/keeping-your-actions-up-to-date-with-dependabot + +version: 2 +updates: + +- package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + labels: + - autosubmit + groups: + github-actions: + patterns: + - "*" diff --git a/pkgs/string_scanner/.github/workflows/publish.yaml b/pkgs/string_scanner/.github/workflows/publish.yaml new file mode 100644 index 000000000..27157a046 --- /dev/null +++ b/pkgs/string_scanner/.github/workflows/publish.yaml @@ -0,0 +1,17 @@ +# A CI configuration to auto-publish pub packages. + +name: Publish + +on: + pull_request: + branches: [ master ] + push: + tags: [ 'v[0-9]+.[0-9]+.[0-9]+' ] + +jobs: + publish: + if: ${{ github.repository_owner == 'dart-lang' }} + uses: dart-lang/ecosystem/.github/workflows/publish.yaml@main + permissions: + id-token: write # Required for authentication using OIDC + pull-requests: write # Required for writing the pull request note diff --git a/pkgs/string_scanner/.github/workflows/test-package.yml b/pkgs/string_scanner/.github/workflows/test-package.yml new file mode 100644 index 000000000..c60f71070 --- /dev/null +++ b/pkgs/string_scanner/.github/workflows/test-package.yml @@ -0,0 +1,64 @@ +name: Dart CI + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ master ] + pull_request: + branches: [ master ] + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + sdk: [3.1, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm + if: always() && steps.install.outcome == 'success' + - name: Run Chrome tests + run: dart test --platform chrome + if: always() && steps.install.outcome == 'success' diff --git a/pkgs/string_scanner/.gitignore b/pkgs/string_scanner/.gitignore new file mode 100644 index 000000000..fb97bdebd --- /dev/null +++ b/pkgs/string_scanner/.gitignore @@ -0,0 +1,5 @@ +# Don’t commit the following directories created by pub. +.dart_tool/ +.pub/ +.packages +pubspec.lock diff --git a/pkgs/string_scanner/CHANGELOG.md b/pkgs/string_scanner/CHANGELOG.md new file mode 100644 index 000000000..082e9f201 --- /dev/null +++ b/pkgs/string_scanner/CHANGELOG.md @@ -0,0 +1,175 @@ +## 1.4.1 + +* Move to `dart-lang/tools` monorepo. + +## 1.4.0 + +* Fix `LineScanner`'s handling of `\r\n`'s to preventing errors scanning + zero-length matches when between CR and LF. CR is treated as a new line only + if not immediately followed by a LF. +* Fix `LineScanner`'s updating of `column` when setting `position` if the + current position is not `0`. + +## 1.3.0 + +* Require Dart 3.1.0 + +* Add a `SpanScanner.spanFromPosition()` method which takes raw code units + rather than `SpanScanner.spanFrom()`'s `LineScannerState`s. + +## 1.2.0 + +* Require Dart 2.18.0 + +* Add better support for reading code points in the Unicode supplementary plane: + + * Added `StringScanner.readCodePoint()`, which consumes an entire Unicode code + point even if it's represented by two UTF-16 code units. + + * Added `StringScanner.peekCodePoint()`, which returns an entire Unicode code + point even if it's represented by two UTF-16 code units. + + * `StringScanner.scanChar()` and `StringScanner.expectChar()` will now + properly consume two UTF-16 code units if they're passed Unicode code points + in the supplementary plane. + +## 1.1.1 + +* Populate the pubspec `repository` field. +* Switch to `package:lints`. +* Remove a dependency on `package:charcode`. + +## 1.1.0 + +* Stable release for null safety. + +## 1.1.0-nullsafety.3 + +* Update SDK constraints to `>=2.12.0-0 <3.0.0` based on beta release + guidelines. + +## 1.1.0-nullsafety.2 + +* Allow prerelease versions of the 2.12 sdk. + +## 1.1.0-nullsafety.1 + +- Allow 2.10 stable and 2.11.0 dev SDK versions. + +## 1.1.0-nullsafety + +- Migrate to null safety. + +## 1.0.5 + +- Added an example. + +- Update Dart SDK constraint to `>=2.0.0 <3.0.0`. + +## 1.0.4 + +* Add @alwaysThrows annotation to error method. + +## 1.0.3 + +* Set max SDK version to `<3.0.0`, and adjust other dependencies. + +## 1.0.2 + +* `SpanScanner` no longer crashes when creating a span that contains a UTF-16 + surrogate pair. + +## 1.0.1 + +* Fix the error text emitted by `StringScanner.expectChar()`. + +## 1.0.0 + +* **Breaking change**: `StringScanner.error()`'s `length` argument now defaults + to `0` rather than `1` when no match data is available. + +* **Breaking change**: `StringScanner.lastMatch` and related methods are now + reset when the scanner's position changes without producing a new match. + +**Note**: While the changes in `1.0.0` are user-visible, they're unlikely to +actually break any code in practice. Unless you know that your package is +incompatible with 0.1.x, consider using 0.1.5 as your lower bound rather +than 1.0.0. For example, `string_scanner: ">=0.1.5 <2.0.0"`. + +## 0.1.5 + +* Add `new SpanScanner.within()`, which scans within a existing `FileSpan`. + +* Add `StringScanner.scanChar()` and `StringScanner.expectChar()`. + +## 0.1.4+1 + +* Remove the dependency on `path`, since we don't actually import it. + +## 0.1.4 + +* Add `new SpanScanner.eager()` for creating a `SpanScanner` that eagerly + computes its current line and column numbers. + +## 0.1.3+2 + +* Fix `LineScanner`'s handling of carriage returns to match that of + `SpanScanner`. + +## 0.1.3+1 + +* Fixed the homepage URL. + +## 0.1.3 + +* Add an optional `endState` argument to `SpanScanner.spanFrom`. + +## 0.1.2 + +* Add `StringScanner.substring`, which returns a substring of the source string. + +## 0.1.1 + +* Declare `SpanScanner`'s exposed `SourceSpan`s and `SourceLocation`s to be + `FileSpan`s and `FileLocation`s. They always were underneath, but callers may + now rely on it. + +* Add `SpanScanner.location`, which returns the scanner's current + `SourceLocation`. + +## 0.1.0 + +* Switch from `source_maps`' `Span` class to `source_span`'s `SourceSpan` class. + +* `new StringScanner()`'s `sourceUrl` parameter is now named to make it clear + that it can be safely `null`. + +* `new StringScannerException()` takes different arguments in a different order + to match `SpanFormatException`. + +* `StringScannerException.string` has been renamed to + `StringScannerException.source` to match the `FormatException` interface. + +## 0.0.3 + +* Make `StringScannerException` inherit from source_map's `SpanFormatException`. + +## 0.0.2 + +* `new StringScanner()` now takes an optional `sourceUrl` argument that provides + the URL of the source file. This is used for error reporting. + +* Add `StringScanner.readChar()` and `StringScanner.peekChar()` methods for + doing character-by-character scanning. + +* Scanners now throw `StringScannerException`s which provide more detailed + access to information about the errors that were thrown and can provide + terminal-colored messages. + +* Add a `LineScanner` subclass of `StringScanner` that automatically tracks line + and column information of the text being scanned. + +* Add a `SpanScanner` subclass of `LineScanner` that exposes matched ranges as + [source map][] `Span` objects. + +[source_map]: https://pub.dev/packages/source_maps diff --git a/pkgs/string_scanner/LICENSE b/pkgs/string_scanner/LICENSE new file mode 100644 index 000000000..000cd7bec --- /dev/null +++ b/pkgs/string_scanner/LICENSE @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/string_scanner/README.md b/pkgs/string_scanner/README.md new file mode 100644 index 000000000..e06e32567 --- /dev/null +++ b/pkgs/string_scanner/README.md @@ -0,0 +1,41 @@ +[![Dart CI](https://github.com/dart-lang/string_scanner/actions/workflows/test-package.yml/badge.svg)](https://github.com/dart-lang/string_scanner/actions/workflows/test-package.yml) +[![pub package](https://img.shields.io/pub/v/string_scanner.svg)](https://pub.dev/packages/string_scanner) +[![package publisher](https://img.shields.io/pub/publisher/string_scanner.svg)](https://pub.dev/packages/string_scanner/publisher) + +This package exposes a `StringScanner` type that makes it easy to parse a string +using a series of `Pattern`s. For example: + +```dart +import 'dart:math' as math; + +import 'package:string_scanner/string_scanner.dart'; + +num parseNumber(String source) { + // Scan a number ("1", "1.5", "-3"). + final scanner = StringScanner(source); + + // [Scanner.scan] tries to consume a [Pattern] and returns whether or not it + // succeeded. It will move the scan pointer past the end of the pattern. + final negative = scanner.scan('-'); + + // [Scanner.expect] consumes a [Pattern] and throws a [FormatError] if it + // fails. Like [Scanner.scan], it will move the scan pointer forward. + scanner.expect(RegExp(r'\d+')); + + // [Scanner.lastMatch] holds the [MatchData] for the most recent call to + // [Scanner.scan], [Scanner.expect], or [Scanner.matches]. + var number = num.parse(scanner.lastMatch![0]!); + + if (scanner.scan('.')) { + scanner.expect(RegExp(r'\d+')); + final decimal = scanner.lastMatch![0]!; + number += int.parse(decimal) / math.pow(10, decimal.length); + } + + // [Scanner.expectDone] will throw a [FormatError] if there's any input that + // hasn't yet been consumed. + scanner.expectDone(); + + return (negative ? -1 : 1) * number; +} +``` diff --git a/pkgs/string_scanner/analysis_options.yaml b/pkgs/string_scanner/analysis_options.yaml new file mode 100644 index 000000000..59f763a4d --- /dev/null +++ b/pkgs/string_scanner/analysis_options.yaml @@ -0,0 +1,32 @@ +# https://dart.dev/guides/language/analysis-options +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + - avoid_bool_literals_in_conditional_expressions + - avoid_classes_with_only_static_members + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_returning_this + - avoid_unused_constructor_parameters + - avoid_void_async + - cancel_subscriptions + - join_return_with_assignment + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_runtimeType_toString + - prefer_const_declarations + - prefer_expression_function_bodies + - prefer_final_locals + - unnecessary_await_in_return + - unnecessary_raw_strings + - use_if_null_to_convert_nulls_to_bools + - use_raw_strings + - use_string_buffers diff --git a/pkgs/string_scanner/example/example.dart b/pkgs/string_scanner/example/example.dart new file mode 100644 index 000000000..ec9dd7660 --- /dev/null +++ b/pkgs/string_scanner/example/example.dart @@ -0,0 +1,40 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:string_scanner/string_scanner.dart'; + +void main(List args) { + print(parseNumber(args.single)); +} + +num parseNumber(String source) { + // Scan a number ("1", "1.5", "-3"). + final scanner = StringScanner(source); + + // [Scanner.scan] tries to consume a [Pattern] and returns whether or not it + // succeeded. It will move the scan pointer past the end of the pattern. + final negative = scanner.scan('-'); + + // [Scanner.expect] consumes a [Pattern] and throws a [FormatError] if it + // fails. Like [Scanner.scan], it will move the scan pointer forward. + scanner.expect(RegExp(r'\d+')); + + // [Scanner.lastMatch] holds the [MatchData] for the most recent call to + // [Scanner.scan], [Scanner.expect], or [Scanner.matches]. + var number = num.parse(scanner.lastMatch![0]!); + + if (scanner.scan('.')) { + scanner.expect(RegExp(r'\d+')); + final decimal = scanner.lastMatch![0]!; + number += int.parse(decimal) / math.pow(10, decimal.length); + } + + // [Scanner.expectDone] will throw a [FormatError] if there's any input that + // hasn't yet been consumed. + scanner.expectDone(); + + return (negative ? -1 : 1) * number; +} diff --git a/pkgs/string_scanner/lib/src/charcode.dart b/pkgs/string_scanner/lib/src/charcode.dart new file mode 100644 index 000000000..d15774935 --- /dev/null +++ b/pkgs/string_scanner/lib/src/charcode.dart @@ -0,0 +1,24 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Character '\'. +const int $backslash = 0x5C; + +/// "Carriage return" control character. +const int $cr = 0x0D; + +/// Character '"'. +const int $doubleQuote = 0x22; + +/// Character 'f'. +const int $f = 0x66; + +/// "Line feed" control character. +const int $lf = 0x0A; + +/// Space character. +const int $space = 0x20; + +/// Character 'x'. +const int $x = 0x78; diff --git a/pkgs/string_scanner/lib/src/eager_span_scanner.dart b/pkgs/string_scanner/lib/src/eager_span_scanner.dart new file mode 100644 index 000000000..1ccc746ac --- /dev/null +++ b/pkgs/string_scanner/lib/src/eager_span_scanner.dart @@ -0,0 +1,133 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'charcode.dart'; +import 'line_scanner.dart'; +import 'span_scanner.dart'; +import 'utils.dart'; + +// TODO(nweiz): Currently this duplicates code in line_scanner.dart. Once +// sdk#23770 is fully complete, we should move the shared code into a mixin. + +/// A regular expression matching newlines across platforms. +final _newlineRegExp = RegExp(r'\r\n?|\n'); + +/// A [SpanScanner] that tracks the line and column eagerly, like [LineScanner]. +class EagerSpanScanner extends SpanScanner { + @override + int get line => _line; + int _line = 0; + + @override + int get column => _column; + int _column = 0; + + @override + LineScannerState get state => + _EagerSpanScannerState(this, position, line, column); + + bool get _betweenCRLF => peekChar(-1) == $cr && peekChar() == $lf; + + @override + set state(LineScannerState state) { + if (state is! _EagerSpanScannerState || !identical(state._scanner, this)) { + throw ArgumentError('The given LineScannerState was not returned by ' + 'this LineScanner.'); + } + + super.position = state.position; + _line = state.line; + _column = state.column; + } + + @override + set position(int newPosition) { + final oldPosition = position; + super.position = newPosition; + + if (newPosition > oldPosition) { + final newlines = _newlinesIn(string.substring(oldPosition, newPosition)); + _line += newlines.length; + if (newlines.isEmpty) { + _column += newPosition - oldPosition; + } else { + _column = newPosition - newlines.last.end; + } + } else { + final newlines = _newlinesIn(string.substring(newPosition, oldPosition)); + if (_betweenCRLF) newlines.removeLast(); + + _line -= newlines.length; + if (newlines.isEmpty) { + _column -= oldPosition - newPosition; + } else { + _column = + newPosition - string.lastIndexOf(_newlineRegExp, newPosition) - 1; + } + } + } + + EagerSpanScanner(super.string, {super.sourceUrl, super.position}); + + @override + bool scanChar(int character) { + if (!super.scanChar(character)) return false; + _adjustLineAndColumn(character); + return true; + } + + @override + int readChar() { + final character = super.readChar(); + _adjustLineAndColumn(character); + return character; + } + + /// Adjusts [_line] and [_column] after having consumed [character]. + void _adjustLineAndColumn(int character) { + if (character == $lf || (character == $cr && peekChar() != $lf)) { + _line += 1; + _column = 0; + } else { + _column += inSupplementaryPlane(character) ? 2 : 1; + } + } + + @override + bool scan(Pattern pattern) { + if (!super.scan(pattern)) return false; + final firstMatch = lastMatch![0]!; + + final newlines = _newlinesIn(firstMatch); + _line += newlines.length; + if (newlines.isEmpty) { + _column += firstMatch.length; + } else { + _column = firstMatch.length - newlines.last.end; + } + + return true; + } + + /// Returns a list of [Match]es describing all the newlines in [text], which + /// is assumed to end at [position]. + List _newlinesIn(String text) { + final newlines = _newlineRegExp.allMatches(text).toList(); + if (_betweenCRLF) newlines.removeLast(); + return newlines; + } +} + +/// A class representing the state of an [EagerSpanScanner]. +class _EagerSpanScannerState implements LineScannerState { + final EagerSpanScanner _scanner; + @override + final int position; + @override + final int line; + @override + final int column; + + _EagerSpanScannerState(this._scanner, this.position, this.line, this.column); +} diff --git a/pkgs/string_scanner/lib/src/exception.dart b/pkgs/string_scanner/lib/src/exception.dart new file mode 100644 index 000000000..57af541ff --- /dev/null +++ b/pkgs/string_scanner/lib/src/exception.dart @@ -0,0 +1,21 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:source_span/source_span.dart'; + +import 'string_scanner.dart'; + +/// An exception thrown by a [StringScanner] that failed to parse a string. +class StringScannerException extends SourceSpanFormatException { + @override + String get source => super.source as String; + + /// The URL of the source file being parsed. + /// + /// This may be `null`, indicating that the source URL is unknown. + Uri? get sourceUrl => span?.sourceUrl; + + StringScannerException( + super.message, SourceSpan super.span, String super.source); +} diff --git a/pkgs/string_scanner/lib/src/line_scanner.dart b/pkgs/string_scanner/lib/src/line_scanner.dart new file mode 100644 index 000000000..b18d61057 --- /dev/null +++ b/pkgs/string_scanner/lib/src/line_scanner.dart @@ -0,0 +1,183 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'charcode.dart'; +import 'string_scanner.dart'; +import 'utils.dart'; + +// Note that much of this code is duplicated in eager_span_scanner.dart. + +/// A regular expression matching newlines. A newline is either a `\n`, a `\r\n` +/// or a `\r` that is not immediately followed by a `\n`. +final _newlineRegExp = RegExp(r'\n|\r\n|\r(?!\n)'); + +/// A subclass of [StringScanner] that tracks line and column information. +class LineScanner extends StringScanner { + /// The scanner's current (zero-based) line number. + int get line => _line; + int _line = 0; + + /// The scanner's current (zero-based) column number. + int get column => _column; + int _column = 0; + + /// The scanner's state, including line and column information. + /// + /// This can be used to efficiently save and restore the state of the scanner + /// when backtracking. A given [LineScannerState] is only valid for the + /// [LineScanner] that created it. + /// + /// This does not include the scanner's match information. + LineScannerState get state => + LineScannerState._(this, position, line, column); + + /// Whether the current position is between a CR character and an LF + /// charactet. + bool get _betweenCRLF => peekChar(-1) == $cr && peekChar() == $lf; + + set state(LineScannerState state) { + if (!identical(state._scanner, this)) { + throw ArgumentError('The given LineScannerState was not returned by ' + 'this LineScanner.'); + } + + super.position = state.position; + _line = state.line; + _column = state.column; + } + + @override + set position(int newPosition) { + if (newPosition == position) { + return; + } + + final oldPosition = position; + super.position = newPosition; + + if (newPosition == 0) { + _line = 0; + _column = 0; + } else if (newPosition > oldPosition) { + final newlines = _newlinesIn(string.substring(oldPosition, newPosition), + endPosition: newPosition); + _line += newlines.length; + if (newlines.isEmpty) { + _column += newPosition - oldPosition; + } else { + // The regex got a substring, so we need to account for where it started + // in the string. + final offsetOfLastNewline = oldPosition + newlines.last.end; + _column = newPosition - offsetOfLastNewline; + } + } else if (newPosition < oldPosition) { + final newlines = _newlinesIn(string.substring(newPosition, oldPosition), + endPosition: oldPosition); + + _line -= newlines.length; + if (newlines.isEmpty) { + _column -= oldPosition - newPosition; + } else { + // To compute the new column, we need to locate the last newline before + // the new position. When searching, we must exclude the CR if we're + // between a CRLF because it's not considered a newline. + final crOffset = _betweenCRLF ? -1 : 0; + // Additionally, if we use newPosition as the end of the search and the + // character at that position itself (the next character) is a newline + // we should not use it, so also offset to account for that. + const currentCharOffset = -1; + final lastNewline = string.lastIndexOf( + _newlineRegExp, newPosition + currentCharOffset + crOffset); + + // Now we need to know the offset after the newline. This is the index + // above plus the length of the newline (eg. if we found `\r\n`) we need + // to add two. However if no newline was found, that index is 0. + final offsetAfterLastNewline = lastNewline == -1 + ? 0 + : string[lastNewline] == '\r' && string[lastNewline + 1] == '\n' + ? lastNewline + 2 + : lastNewline + 1; + + _column = newPosition - offsetAfterLastNewline; + } + } + } + + LineScanner(super.string, {super.sourceUrl, super.position}); + + @override + bool scanChar(int character) { + if (!super.scanChar(character)) return false; + _adjustLineAndColumn(character); + return true; + } + + @override + int readChar() { + final character = super.readChar(); + _adjustLineAndColumn(character); + return character; + } + + /// Adjusts [_line] and [_column] after having consumed [character]. + void _adjustLineAndColumn(int character) { + if (character == $lf || (character == $cr && peekChar() != $lf)) { + _line += 1; + _column = 0; + } else { + _column += inSupplementaryPlane(character) ? 2 : 1; + } + } + + @override + bool scan(Pattern pattern) { + if (!super.scan(pattern)) return false; + + final newlines = _newlinesIn(lastMatch![0]!, endPosition: position); + _line += newlines.length; + if (newlines.isEmpty) { + _column += lastMatch![0]!.length; + } else { + _column = lastMatch![0]!.length - newlines.last.end; + } + + return true; + } + + /// Returns a list of [Match]es describing all the newlines in [text], which + /// ends at [endPosition]. + /// + /// If [text] ends with `\r`, it will only be treated as a newline if the next + /// character at [position] is not a `\n`. + List _newlinesIn(String text, {required int endPosition}) { + final newlines = _newlineRegExp.allMatches(text).toList(); + // If the last character is a `\r` it will have been treated as a newline, + // but this is only valid if the next character is not a `\n`. + if (endPosition < string.length && + text.endsWith('\r') && + string[endPosition] == '\n') { + // newlines should never be empty here, because if `text` ends with `\r` + // it would have matched `\r(?!\n)` in the newline regex. + newlines.removeLast(); + } + return newlines; + } +} + +/// A class representing the state of a [LineScanner]. +class LineScannerState { + /// The [LineScanner] that created this. + final LineScanner _scanner; + + /// The position of the scanner in this state. + final int position; + + /// The zero-based line number of the scanner in this state. + final int line; + + /// The zero-based column number of the scanner in this state. + final int column; + + LineScannerState._(this._scanner, this.position, this.line, this.column); +} diff --git a/pkgs/string_scanner/lib/src/relative_span_scanner.dart b/pkgs/string_scanner/lib/src/relative_span_scanner.dart new file mode 100644 index 000000000..cd9af0e03 --- /dev/null +++ b/pkgs/string_scanner/lib/src/relative_span_scanner.dart @@ -0,0 +1,132 @@ +// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:source_span/source_span.dart'; + +import 'exception.dart'; +import 'line_scanner.dart'; +import 'span_scanner.dart'; +import 'string_scanner.dart'; +import 'utils.dart'; + +/// A [SpanScanner] that scans within an existing [FileSpan]. +/// +/// This re-implements chunks of [SpanScanner] rather than using a dummy span or +/// inheritance because scanning is often a performance-critical operation, so +/// it's important to avoid adding extra overhead when relative scanning isn't +/// needed. +class RelativeSpanScanner extends StringScanner implements SpanScanner { + /// The source of the scanner. + /// + /// This caches line break information and is used to generate [SourceSpan]s. + final SourceFile _sourceFile; + + /// The start location of the span within which this scanner is scanning. + /// + /// This is used to convert between span-relative and file-relative fields. + final FileLocation _startLocation; + + @override + int get line => + _sourceFile.getLine(_startLocation.offset + position) - + _startLocation.line; + + @override + int get column { + final line = _sourceFile.getLine(_startLocation.offset + position); + final column = + _sourceFile.getColumn(_startLocation.offset + position, line: line); + return line == _startLocation.line + ? column - _startLocation.column + : column; + } + + @override + LineScannerState get state => _SpanScannerState(this, position); + + @override + set state(LineScannerState state) { + if (state is! _SpanScannerState || !identical(state._scanner, this)) { + throw ArgumentError('The given LineScannerState was not returned by ' + 'this LineScanner.'); + } + + position = state.position; + } + + @override + FileSpan? get lastSpan => _lastSpan; + FileSpan? _lastSpan; + + @override + FileLocation get location => + _sourceFile.location(_startLocation.offset + position); + + @override + FileSpan get emptySpan => location.pointSpan(); + + RelativeSpanScanner(FileSpan span) + : _sourceFile = span.file, + _startLocation = span.start, + super(span.text, sourceUrl: span.sourceUrl); + + @override + FileSpan spanFrom(LineScannerState startState, [LineScannerState? endState]) { + final endPosition = endState == null ? position : endState.position; + return _sourceFile.span(_startLocation.offset + startState.position, + _startLocation.offset + endPosition); + } + + @override + FileSpan spanFromPosition(int startPosition, [int? endPosition]) { + RangeError.checkValidRange( + startPosition, + endPosition, + _sourceFile.length - _startLocation.offset, + 'startPosition', + 'endPosition'); + return _sourceFile.span(_startLocation.offset + startPosition, + _startLocation.offset + (endPosition ?? position)); + } + + @override + bool matches(Pattern pattern) { + if (!super.matches(pattern)) { + _lastSpan = null; + return false; + } + + _lastSpan = _sourceFile.span(_startLocation.offset + position, + _startLocation.offset + lastMatch!.end); + return true; + } + + @override + Never error(String message, {Match? match, int? position, int? length}) { + validateErrorArgs(string, match, position, length); + + if (match == null && position == null && length == null) match = lastMatch; + position ??= match == null ? this.position : match.start; + length ??= match == null ? 1 : match.end - match.start; + + final span = _sourceFile.span(_startLocation.offset + position, + _startLocation.offset + position + length); + throw StringScannerException(message, span, string); + } +} + +/// A class representing the state of a [SpanScanner]. +class _SpanScannerState implements LineScannerState { + /// The [SpanScanner] that created this. + final RelativeSpanScanner _scanner; + + @override + final int position; + @override + int get line => _scanner._sourceFile.getLine(position); + @override + int get column => _scanner._sourceFile.getColumn(position); + + _SpanScannerState(this._scanner, this.position); +} diff --git a/pkgs/string_scanner/lib/src/span_scanner.dart b/pkgs/string_scanner/lib/src/span_scanner.dart new file mode 100644 index 000000000..509cf6004 --- /dev/null +++ b/pkgs/string_scanner/lib/src/span_scanner.dart @@ -0,0 +1,142 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:source_span/source_span.dart'; + +import 'eager_span_scanner.dart'; +import 'exception.dart'; +import 'line_scanner.dart'; +import 'relative_span_scanner.dart'; +import 'string_scanner.dart'; +import 'utils.dart'; + +/// A subclass of [LineScanner] that exposes matched ranges as source map +/// [FileSpan]s. +class SpanScanner extends StringScanner implements LineScanner { + /// The source of the scanner. + /// + /// This caches line break information and is used to generate [FileSpan]s. + final SourceFile _sourceFile; + + @override + int get line => _sourceFile.getLine(position); + @override + int get column => _sourceFile.getColumn(position); + + @override + LineScannerState get state => _SpanScannerState(this, position); + + @override + set state(LineScannerState state) { + if (state is! _SpanScannerState || !identical(state._scanner, this)) { + throw ArgumentError('The given LineScannerState was not returned by ' + 'this LineScanner.'); + } + + position = state.position; + } + + /// The [FileSpan] for [lastMatch]. + /// + /// This is the span for the entire match. There's no way to get spans for + /// subgroups since [Match] exposes no information about their positions. + FileSpan? get lastSpan { + if (lastMatch == null) _lastSpan = null; + return _lastSpan; + } + + FileSpan? _lastSpan; + + /// The current location of the scanner. + FileLocation get location => _sourceFile.location(position); + + /// Returns an empty span at the current location. + FileSpan get emptySpan => location.pointSpan(); + + /// Creates a new [SpanScanner] that starts scanning from [position]. + /// + /// [sourceUrl] is used as [SourceLocation.sourceUrl] for the returned + /// [FileSpan]s as well as for error reporting. It can be a [String], a + /// [Uri], or `null`. + SpanScanner(super.string, {super.sourceUrl, super.position}) + : _sourceFile = SourceFile.fromString(string, url: sourceUrl); + + /// Creates a new [SpanScanner] that eagerly computes line and column numbers. + /// + /// In general [SpanScanner.new] will be more efficient, since it avoids extra + /// computation on every scan. However, eager scanning can be useful for + /// situations where the normal course of parsing frequently involves + /// accessing the current line and column numbers. + /// + /// Note that *only* the `line` and `column` fields on the `SpanScanner` + /// itself and its `LineScannerState` are eagerly computed. To limit their + /// memory footprint, returned spans and locations will still lazily compute + /// their line and column numbers. + factory SpanScanner.eager(String string, {sourceUrl, int? position}) = + EagerSpanScanner; + + /// Creates a new [SpanScanner] that scans within [span]. + /// + /// This scans through [span]`.text, but emits new spans from [span]`.file` in + /// their appropriate relative positions. The [string] field contains only + /// [span]`.text`, and [position], [line], and [column] are all relative to + /// the span. + factory SpanScanner.within(FileSpan span) = RelativeSpanScanner; + + /// Creates a [FileSpan] representing the source range between [startState] + /// and the current position. + FileSpan spanFrom(LineScannerState startState, [LineScannerState? endState]) { + final endPosition = endState == null ? position : endState.position; + return _sourceFile.span(startState.position, endPosition); + } + + /// Creates a [FileSpan] representing the source range between [startPosition] + /// and [endPosition], or the current position if [endPosition] is null. + /// + /// Each position should be a code unit offset into the string being scanned, + /// with the same conventions as [StringScanner.position]. + /// + /// Throws a [RangeError] if [startPosition] or [endPosition] aren't within + /// this source file. + FileSpan spanFromPosition(int startPosition, [int? endPosition]) => + _sourceFile.span(startPosition, endPosition ?? position); + + @override + bool matches(Pattern pattern) { + if (!super.matches(pattern)) { + _lastSpan = null; + return false; + } + + _lastSpan = _sourceFile.span(position, lastMatch!.end); + return true; + } + + @override + Never error(String message, {Match? match, int? position, int? length}) { + validateErrorArgs(string, match, position, length); + + if (match == null && position == null && length == null) match = lastMatch; + position ??= match == null ? this.position : match.start; + length ??= match == null ? 0 : match.end - match.start; + + final span = _sourceFile.span(position, position + length); + throw StringScannerException(message, span, string); + } +} + +/// A class representing the state of a [SpanScanner]. +class _SpanScannerState implements LineScannerState { + /// The [SpanScanner] that created this. + final SpanScanner _scanner; + + @override + final int position; + @override + int get line => _scanner._sourceFile.getLine(position); + @override + int get column => _scanner._sourceFile.getColumn(position); + + _SpanScannerState(this._scanner, this.position); +} diff --git a/pkgs/string_scanner/lib/src/string_scanner.dart b/pkgs/string_scanner/lib/src/string_scanner.dart new file mode 100644 index 000000000..146694432 --- /dev/null +++ b/pkgs/string_scanner/lib/src/string_scanner.dart @@ -0,0 +1,272 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:source_span/source_span.dart'; + +import 'charcode.dart'; +import 'exception.dart'; +import 'utils.dart'; + +/// A class that scans through a string using [Pattern]s. +class StringScanner { + /// The URL of the source of the string being scanned. + /// + /// This is used for error reporting. It may be `null`, indicating that the + /// source URL is unknown or unavailable. + final Uri? sourceUrl; + + /// The string being scanned through. + final String string; + + /// The current position of the scanner in the string, in characters. + int get position => _position; + set position(int position) { + if (position.isNegative || position > string.length) { + throw ArgumentError('Invalid position $position'); + } + + _position = position; + _lastMatch = null; + } + + int _position = 0; + + /// The data about the previous match made by the scanner. + /// + /// If the last match failed, this will be `null`. + Match? get lastMatch { + // Lazily unset [_lastMatch] so that we avoid extra assignments in + // character-by-character methods that are used in core loops. + if (_position != _lastMatchPosition) _lastMatch = null; + return _lastMatch; + } + + Match? _lastMatch; + int? _lastMatchPosition; + + /// The portion of the string that hasn't yet been scanned. + String get rest => string.substring(position); + + /// Whether the scanner has completely consumed [string]. + bool get isDone => position == string.length; + + /// Creates a new [StringScanner] that starts scanning from [position]. + /// + /// [position] defaults to 0, the beginning of the string. [sourceUrl] is the + /// URL of the source of the string being scanned, if available. It can be + /// a [String], a [Uri], or `null`. + StringScanner(this.string, {Object? sourceUrl, int? position}) + : sourceUrl = sourceUrl == null + ? null + : sourceUrl is String + ? Uri.parse(sourceUrl) + : sourceUrl as Uri { + if (position != null) this.position = position; + } + + /// Consumes a single character and returns its character code. + /// + /// This throws a [FormatException] if the string has been fully consumed. It + /// doesn't affect [lastMatch]. + int readChar() { + if (isDone) _fail('more input'); + return string.codeUnitAt(_position++); + } + + /// Returns the character code of the character [offset] away from [position]. + /// + /// [offset] defaults to zero, and may be negative to inspect already-consumed + /// characters. + /// + /// This returns `null` if [offset] points outside the string. It doesn't + /// affect [lastMatch]. + int? peekChar([int? offset]) { + offset ??= 0; + final index = position + offset; + if (index < 0 || index >= string.length) return null; + return string.codeUnitAt(index); + } + + /// If the next character in the string is [character], consumes it. + /// + /// If [character] is a Unicode code point in a supplementary plane, this will + /// consume two code units. Dart's string representation is UTF-16, which + /// represents supplementary-plane code units as two code units. + /// + /// Returns whether or not [character] was consumed. + bool scanChar(int character) { + if (inSupplementaryPlane(character)) { + if (_position + 1 >= string.length || + string.codeUnitAt(_position) != highSurrogate(character) || + string.codeUnitAt(_position + 1) != lowSurrogate(character)) { + return false; + } else { + _position += 2; + return true; + } + } else { + if (isDone) return false; + if (string.codeUnitAt(_position) != character) return false; + _position++; + return true; + } + } + + /// If the next character in the string is [character], consumes it. + /// + /// If [character] is a Unicode code point in a supplementary plane, this will + /// consume two code units. Dart's string representation is UTF-16, which + /// represents supplementary-plane code units as two code units. + /// + /// If [character] could not be consumed, throws a [FormatException] + /// describing the position of the failure. [name] is used in this error as + /// the expected name of the character being matched; if it's `null`, the + /// character itself is used instead. + void expectChar(int character, {String? name}) { + if (scanChar(character)) return; + + if (name == null) { + if (character == $backslash) { + name = r'"\"'; + } else if (character == $doubleQuote) { + name = r'"\""'; + } else { + name = '"${String.fromCharCode(character)}"'; + } + } + + _fail(name); + } + + /// Consumes a single Unicode code unit and returns it. + /// + /// This works like [readChar], except that it automatically handles UTF-16 + /// surrogate pairs. Specifically, if the next two code units form a surrogate + /// pair, consumes them both and returns the corresponding Unicode code point. + /// + /// If next two characters are not a surrogate pair, the next code unit is + /// returned as-is, even if it's an unpaired surrogate. + int readCodePoint() { + final first = readChar(); + if (!isHighSurrogate(first)) return first; + + final next = peekChar(); + if (next == null || !isLowSurrogate(next)) return first; + + readChar(); + return decodeSurrogatePair(first, next); + } + + /// Returns the Unicode code point immediately after [position]. + /// + /// This works like [peekChar], except that it automatically handles UTF-16 + /// surrogate pairs. Specifically, if the next two code units form a surrogate + /// pair, returns the corresponding Unicode code point. + /// + /// If next two characters are not a surrogate pair, the next code unit is + /// returned as-is, even if it's an unpaired surrogate. + int? peekCodePoint() { + final first = peekChar(); + if (first == null || !isHighSurrogate(first)) return first; + + final next = peekChar(1); + if (next == null || !isLowSurrogate(next)) return first; + + return decodeSurrogatePair(first, next); + } + + /// If [pattern] matches at the current position of the string, scans forward + /// until the end of the match. + /// + /// Returns whether or not [pattern] matched. + bool scan(Pattern pattern) { + final success = matches(pattern); + if (success) { + _position = _lastMatch!.end; + _lastMatchPosition = _position; + } + return success; + } + + /// If [pattern] matches at the current position of the string, scans forward + /// until the end of the match. + /// + /// If [pattern] did not match, throws a [FormatException] describing the + /// position of the failure. [name] is used in this error as the expected name + /// of the pattern being matched; if it's `null`, the pattern itself is used + /// instead. + void expect(Pattern pattern, {String? name}) { + if (scan(pattern)) return; + + if (name == null) { + if (pattern is RegExp) { + final source = pattern.pattern; + name = '/$source/'; + } else { + name = + pattern.toString().replaceAll(r'\', r'\\').replaceAll('"', r'\"'); + name = '"$name"'; + } + } + _fail(name); + } + + /// If the string has not been fully consumed, this throws a + /// [FormatException]. + void expectDone() { + if (isDone) return; + _fail('no more input'); + } + + /// Returns whether or not [pattern] matches at the current position of the + /// string. + /// + /// This doesn't move the scan pointer forward. + bool matches(Pattern pattern) { + _lastMatch = pattern.matchAsPrefix(string, position); + _lastMatchPosition = _position; + return _lastMatch != null; + } + + /// Returns the substring of [string] between [start] and [end]. + /// + /// Unlike [String.substring], [end] defaults to [position] rather than the + /// end of the string. + String substring(int start, [int? end]) { + end ??= position; + return string.substring(start, end); + } + + /// Throws a [FormatException] with [message] as well as a detailed + /// description of the location of the error in the string. + /// + /// [match] is the match information for the span of the string with which the + /// error is associated. This should be a match returned by this scanner's + /// [lastMatch] property. By default, the error is associated with the last + /// match. + /// + /// If [position] and/or [length] are passed, they are used as the error span + /// instead. If only [length] is passed, [position] defaults to the current + /// position; if only [position] is passed, [length] defaults to 0. + /// + /// It's an error to pass [match] at the same time as [position] or [length]. + Never error(String message, {Match? match, int? position, int? length}) { + validateErrorArgs(string, match, position, length); + + if (match == null && position == null && length == null) match = lastMatch; + position ??= match == null ? this.position : match.start; + length ??= match == null ? 0 : match.end - match.start; + + final sourceFile = SourceFile.fromString(string, url: sourceUrl); + final span = sourceFile.span(position, position + length); + throw StringScannerException(message, span, string); + } + + // TODO(nweiz): Make this handle long lines more gracefully. + /// Throws a [FormatException] describing that [name] is expected at the + /// current position in the string. + Never _fail(String name) { + error('expected $name.', position: position, length: 0); + } +} diff --git a/pkgs/string_scanner/lib/src/utils.dart b/pkgs/string_scanner/lib/src/utils.dart new file mode 100644 index 000000000..39891a16a --- /dev/null +++ b/pkgs/string_scanner/lib/src/utils.dart @@ -0,0 +1,95 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'string_scanner.dart'; + +/// Validates the arguments passed to [StringScanner.error]. +void validateErrorArgs( + String string, Match? match, int? position, int? length) { + if (match != null && (position != null || length != null)) { + throw ArgumentError("Can't pass both match and position/length."); + } + + if (position != null) { + if (position < 0) { + throw RangeError('position must be greater than or equal to 0.'); + } else if (position > string.length) { + throw RangeError('position must be less than or equal to the ' + 'string length.'); + } + } + + if (length != null && length < 0) { + throw RangeError('length must be greater than or equal to 0.'); + } + + if (position != null && length != null && position + length > string.length) { + throw RangeError('position plus length must not go beyond the end of ' + 'the string.'); + } +} + +// See https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF +// for documentation on how UTF-16 encoding works and definitions of various +// related terms. + +/// The inclusive lower bound of Unicode's supplementary plane. +const _supplementaryPlaneLowerBound = 0x10000; + +/// The inclusive upper bound of Unicode's supplementary plane. +const _supplementaryPlaneUpperBound = 0x10FFFF; + +/// The inclusive lower bound of the UTF-16 high surrogate block. +const _highSurrogateLowerBound = 0xD800; + +/// The inclusive lower bound of the UTF-16 low surrogate block. +const _lowSurrogateLowerBound = 0xDC00; + +/// The number of low bits in each code unit of a surrogate pair that goes into +/// determining which code point it encodes. +const _surrogateBits = 10; + +/// A bit mask that covers the lower [_surrogateBits] of a code point, which can +/// be used to extract the value of a surrogate or the low surrogate value of a +/// code unit. +const _surrogateValueMask = (1 << _surrogateBits) - 1; + +/// Returns whether [codePoint] is in the Unicode supplementary plane, and thus +/// must be represented as a surrogate pair in UTF-16. +bool inSupplementaryPlane(int codePoint) => + codePoint >= _supplementaryPlaneLowerBound && + codePoint <= _supplementaryPlaneUpperBound; + +/// Returns whether [codeUnit] is a UTF-16 high surrogate. +bool isHighSurrogate(int codeUnit) => + (codeUnit & ~_surrogateValueMask) == _highSurrogateLowerBound; + +/// Returns whether [codeUnit] is a UTF-16 low surrogate. +bool isLowSurrogate(int codeUnit) => + (codeUnit >> _surrogateBits) == (_lowSurrogateLowerBound >> _surrogateBits); + +/// Returns the high surrogate needed to encode the supplementary-plane +/// [codePoint]. +int highSurrogate(int codePoint) { + assert(inSupplementaryPlane(codePoint)); + return ((codePoint - _supplementaryPlaneLowerBound) >> _surrogateBits) + + _highSurrogateLowerBound; +} + +/// Returns the low surrogate needed to encode the supplementary-plane +/// [codePoint]. +int lowSurrogate(int codePoint) { + assert(inSupplementaryPlane(codePoint)); + return ((codePoint - _supplementaryPlaneLowerBound) & _surrogateValueMask) + + _lowSurrogateLowerBound; +} + +/// Converts a UTF-16 surrogate pair into the Unicode code unit it represents. +int decodeSurrogatePair(int highSurrogate, int lowSurrogate) { + assert(isHighSurrogate(highSurrogate)); + assert(isLowSurrogate(lowSurrogate)); + return _supplementaryPlaneLowerBound + + (((highSurrogate & _surrogateValueMask) << _surrogateBits) | + (lowSurrogate & _surrogateValueMask)); +} diff --git a/pkgs/string_scanner/lib/string_scanner.dart b/pkgs/string_scanner/lib/string_scanner.dart new file mode 100644 index 000000000..e641ae706 --- /dev/null +++ b/pkgs/string_scanner/lib/string_scanner.dart @@ -0,0 +1,11 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// A library for parsing strings using a sequence of patterns. +library; + +export 'src/exception.dart'; +export 'src/line_scanner.dart'; +export 'src/span_scanner.dart'; +export 'src/string_scanner.dart'; diff --git a/pkgs/string_scanner/pubspec.yaml b/pkgs/string_scanner/pubspec.yaml new file mode 100644 index 000000000..9b259cf7b --- /dev/null +++ b/pkgs/string_scanner/pubspec.yaml @@ -0,0 +1,14 @@ +name: string_scanner +version: 1.4.1 +description: A class for parsing strings using a sequence of patterns. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/string_scanner + +environment: + sdk: ^3.1.0 + +dependencies: + source_span: ^1.8.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + test: ^1.16.6 diff --git a/pkgs/string_scanner/test/error_test.dart b/pkgs/string_scanner/test/error_test.dart new file mode 100644 index 000000000..1f98c3243 --- /dev/null +++ b/pkgs/string_scanner/test/error_test.dart @@ -0,0 +1,143 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:string_scanner/string_scanner.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + test('defaults to the last match', () { + final scanner = StringScanner('foo bar baz'); + scanner.expect('foo '); + scanner.expect('bar'); + expect(() => scanner.error('oh no!'), throwsStringScannerException('bar')); + }); + + group('with match', () { + test('supports an earlier match', () { + final scanner = StringScanner('foo bar baz'); + scanner.expect('foo '); + final match = scanner.lastMatch; + scanner.expect('bar'); + expect(() => scanner.error('oh no!', match: match), + throwsStringScannerException('foo ')); + }); + + test('supports a match on a previous line', () { + final scanner = StringScanner('foo bar baz\ndo re mi\nearth fire water'); + scanner.expect('foo bar baz\ndo '); + scanner.expect('re'); + final match = scanner.lastMatch; + scanner.expect(' mi\nearth '); + expect(() => scanner.error('oh no!', match: match), + throwsStringScannerException('re')); + }); + + test('supports a multiline match', () { + final scanner = StringScanner('foo bar baz\ndo re mi\nearth fire water'); + scanner.expect('foo bar '); + scanner.expect('baz\ndo'); + final match = scanner.lastMatch; + scanner.expect(' re mi'); + expect(() => scanner.error('oh no!', match: match), + throwsStringScannerException('baz\ndo')); + }); + + test('supports a match after position', () { + final scanner = StringScanner('foo bar baz'); + scanner.expect('foo '); + scanner.expect('bar'); + final match = scanner.lastMatch; + scanner.position = 0; + expect(() => scanner.error('oh no!', match: match), + throwsStringScannerException('bar')); + }); + }); + + group('with position and/or length', () { + test('defaults to length 0', () { + final scanner = StringScanner('foo bar baz'); + scanner.expect('foo '); + expect(() => scanner.error('oh no!', position: 1), + throwsStringScannerException('')); + }); + + test('defaults to the current position', () { + final scanner = StringScanner('foo bar baz'); + scanner.expect('foo '); + expect(() => scanner.error('oh no!', length: 3), + throwsStringScannerException('bar')); + }); + + test('supports an earlier position', () { + final scanner = StringScanner('foo bar baz'); + scanner.expect('foo '); + expect(() => scanner.error('oh no!', position: 1, length: 2), + throwsStringScannerException('oo')); + }); + + test('supports a position on a previous line', () { + final scanner = StringScanner('foo bar baz\ndo re mi\nearth fire water'); + scanner.expect('foo bar baz\ndo re mi\nearth'); + expect(() => scanner.error('oh no!', position: 15, length: 2), + throwsStringScannerException('re')); + }); + + test('supports a multiline length', () { + final scanner = StringScanner('foo bar baz\ndo re mi\nearth fire water'); + scanner.expect('foo bar baz\ndo re mi\nearth'); + expect(() => scanner.error('oh no!', position: 8, length: 8), + throwsStringScannerException('baz\ndo r')); + }); + + test('supports a position after the current one', () { + final scanner = StringScanner('foo bar baz'); + expect(() => scanner.error('oh no!', position: 4, length: 3), + throwsStringScannerException('bar')); + }); + + test('supports a length of zero', () { + final scanner = StringScanner('foo bar baz'); + expect(() => scanner.error('oh no!', position: 4, length: 0), + throwsStringScannerException('')); + }); + }); + + group('argument errors', () { + late StringScanner scanner; + setUp(() { + scanner = StringScanner('foo bar baz'); + scanner.scan('foo'); + }); + + test('if match is passed with position', () { + expect( + () => scanner.error('oh no!', match: scanner.lastMatch, position: 1), + throwsArgumentError); + }); + + test('if match is passed with length', () { + expect(() => scanner.error('oh no!', match: scanner.lastMatch, length: 1), + throwsArgumentError); + }); + + test('if position is negative', () { + expect(() => scanner.error('oh no!', position: -1), throwsArgumentError); + }); + + test('if position is outside the string', () { + expect(() => scanner.error('oh no!', position: 100), throwsArgumentError); + }); + + test('if position + length is outside the string', () { + expect(() => scanner.error('oh no!', position: 7, length: 7), + throwsArgumentError); + }); + + test('if length is negative', () { + expect(() => scanner.error('oh no!', length: -1), throwsArgumentError); + }); + }); +} diff --git a/pkgs/string_scanner/test/line_scanner_test.dart b/pkgs/string_scanner/test/line_scanner_test.dart new file mode 100644 index 000000000..1af5c3666 --- /dev/null +++ b/pkgs/string_scanner/test/line_scanner_test.dart @@ -0,0 +1,465 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:string_scanner/src/charcode.dart'; +import 'package:string_scanner/string_scanner.dart'; +import 'package:test/test.dart'; + +void main() { + late LineScanner scanner; + setUp(() { + scanner = LineScanner('foo\nbar\r\nbaz'); + }); + + test('begins with line and column 0', () { + expect(scanner.line, equals(0)); + expect(scanner.column, equals(0)); + }); + + group('scan()', () { + test('consuming no newlines increases the column but not the line', () { + scanner.expect('foo'); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(3)); + }); + + test('consuming a LF resets the column and increases the line', () { + scanner.expect('foo\nba'); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(2)); + }); + + test('consuming multiple LFs resets the column and increases the line', () { + scanner.expect('foo\nbar\r\nb'); + expect(scanner.line, equals(2)); + expect(scanner.column, equals(1)); + }); + + test('consuming a CR LF increases the line only after the LF', () { + scanner.expect('foo\nbar\r'); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(4)); + + scanner.expect('\nb'); + expect(scanner.line, equals(2)); + expect(scanner.column, equals(1)); + }); + + test('consuming a CR not followed by LF increases the line', () { + scanner = LineScanner('foo\nbar\rbaz'); + scanner.expect('foo\nbar\r'); + expect(scanner.line, equals(2)); + expect(scanner.column, equals(0)); + + scanner.expect('b'); + expect(scanner.line, equals(2)); + expect(scanner.column, equals(1)); + }); + + test('consuming a CR at the end increases the line', () { + scanner = LineScanner('foo\nbar\r'); + scanner.expect('foo\nbar\r'); + expect(scanner.line, equals(2)); + expect(scanner.column, equals(0)); + expect(scanner.isDone, isTrue); + }); + + test('consuming a mix of CR, LF, CR+LF increases the line', () { + scanner = LineScanner('0\n1\r2\r\n3'); + scanner.expect('0\n1\r2\r\n3'); + expect(scanner.line, equals(3)); + expect(scanner.column, equals(1)); + }); + + test('scanning a zero length match between CR LF does not fail', () { + scanner.expect('foo\nbar\r'); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(4)); + scanner.expect(RegExp('(?!x)')); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(4)); + }); + }); + + group('readChar()', () { + test('on a non-newline character increases the column but not the line', + () { + scanner.readChar(); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(1)); + }); + + test('consuming a LF resets the column and increases the line', () { + scanner.expect('foo'); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(3)); + + scanner.readChar(); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(0)); + }); + + test('consuming a CR LF increases the line only after the LF', () { + scanner = LineScanner('foo\r\nbar'); + scanner.expect('foo'); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(3)); + + scanner.readChar(); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(4)); + + scanner.readChar(); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(0)); + }); + + test('consuming a CR not followed by a LF increases the line', () { + scanner = LineScanner('foo\nbar\rbaz'); + scanner.expect('foo\nbar'); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(3)); + + scanner.readChar(); + expect(scanner.line, equals(2)); + expect(scanner.column, equals(0)); + }); + + test('consuming a CR at the end increases the line', () { + scanner = LineScanner('foo\nbar\r'); + scanner.expect('foo\nbar'); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(3)); + + scanner.readChar(); + expect(scanner.line, equals(2)); + expect(scanner.column, equals(0)); + }); + + test('consuming a mix of CR, LF, CR+LF increases the line', () { + scanner = LineScanner('0\n1\r2\r\n3'); + for (var i = 0; i < scanner.string.length; i++) { + scanner.readChar(); + } + + expect(scanner.line, equals(3)); + expect(scanner.column, equals(1)); + }); + }); + + group('readCodePoint()', () { + test('on a non-newline character increases the column but not the line', + () { + scanner.readCodePoint(); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(1)); + }); + + test('consuming a newline resets the column and increases the line', () { + scanner.expect('foo'); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(3)); + + scanner.readCodePoint(); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(0)); + }); + + test("consuming halfway through a CR LF doesn't count as a line", () { + scanner.expect('foo\nbar'); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(3)); + + scanner.readCodePoint(); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(4)); + + scanner.readCodePoint(); + expect(scanner.line, equals(2)); + expect(scanner.column, equals(0)); + }); + }); + + group('scanChar()', () { + test('on a non-newline character increases the column but not the line', + () { + scanner.scanChar($f); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(1)); + }); + + test('consuming a LF resets the column and increases the line', () { + scanner.expect('foo'); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(3)); + + scanner.scanChar($lf); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(0)); + }); + + test('consuming a CR LF increases the line only after the LF', () { + scanner.expect('foo\nbar'); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(3)); + + scanner.scanChar($cr); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(4)); + + scanner.scanChar($lf); + expect(scanner.line, equals(2)); + expect(scanner.column, equals(0)); + }); + + test('consuming a CR not followed by LF increases the line', () { + scanner = LineScanner('foo\rbar'); + scanner.expect('foo'); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(3)); + + scanner.scanChar($cr); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(0)); + }); + + test('consuming a CR at the end increases the line', () { + scanner = LineScanner('foo\r'); + scanner.expect('foo'); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(3)); + + scanner.scanChar($cr); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(0)); + }); + + test('consuming a mix of CR, LF, CR+LF increases the line', () { + scanner = LineScanner('0\n1\r2\r\n3'); + for (var i = 0; i < scanner.string.length; i++) { + scanner.scanChar(scanner.string[i].codeUnits.single); + } + + expect(scanner.line, equals(3)); + expect(scanner.column, equals(1)); + }); + }); + + group('before a surrogate pair', () { + final codePoint = '\uD83D\uDC6D'.runes.first; + const highSurrogate = 0xD83D; + + late LineScanner scanner; + setUp(() { + scanner = LineScanner('foo: \uD83D\uDC6D'); + expect(scanner.scan('foo: '), isTrue); + }); + + test('readChar returns the high surrogate and moves into the pair', () { + expect(scanner.readChar(), equals(highSurrogate)); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(6)); + expect(scanner.position, equals(6)); + }); + + test('readCodePoint returns the code unit and moves past the pair', () { + expect(scanner.readCodePoint(), equals(codePoint)); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(7)); + expect(scanner.position, equals(7)); + }); + + test('scanChar with the high surrogate moves into the pair', () { + expect(scanner.scanChar(highSurrogate), isTrue); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(6)); + expect(scanner.position, equals(6)); + }); + + test('scanChar with the code point moves past the pair', () { + expect(scanner.scanChar(codePoint), isTrue); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(7)); + expect(scanner.position, equals(7)); + }); + + test('expectChar with the high surrogate moves into the pair', () { + scanner.expectChar(highSurrogate); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(6)); + expect(scanner.position, equals(6)); + }); + + test('expectChar with the code point moves past the pair', () { + scanner.expectChar(codePoint); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(7)); + expect(scanner.position, equals(7)); + }); + }); + + group('position=', () { + test('forward through LFs sets the line and column', () { + scanner = LineScanner('foo\nbar\nbaz'); + scanner.position = 9; // "foo\nbar\nb" + expect(scanner.line, equals(2)); + expect(scanner.column, equals(1)); + }); + + test('forward from non-zero character through LFs sets the line and column', + () { + scanner = LineScanner('foo\nbar\nbaz'); + scanner.expect('fo'); + scanner.position = 9; // "foo\nbar\nb" + expect(scanner.line, equals(2)); + expect(scanner.column, equals(1)); + }); + + test('forward through CR LFs sets the line and column', () { + scanner = LineScanner('foo\r\nbar\r\nbaz'); + scanner.position = 11; // "foo\r\nbar\r\nb" + expect(scanner.line, equals(2)); + expect(scanner.column, equals(1)); + }); + + test('forward through CR not followed by LFs sets the line and column', () { + scanner = LineScanner('foo\rbar\rbaz'); + scanner.position = 9; // "foo\rbar\rb" + expect(scanner.line, equals(2)); + expect(scanner.column, equals(1)); + }); + + test('forward through CR at end sets the line and column', () { + scanner = LineScanner('foo\rbar\r'); + scanner.position = 8; // "foo\rbar\r" + expect(scanner.line, equals(2)); + expect(scanner.column, equals(0)); + }); + + test('forward through a mix of CR, LF, CR+LF sets the line and column', () { + scanner = LineScanner('0\n1\r2\r\n3'); + scanner.position = scanner.string.length; + + expect(scanner.line, equals(3)); + expect(scanner.column, equals(1)); + }); + + test('forward through no newlines sets the column', () { + scanner.position = 2; // "fo" + expect(scanner.line, equals(0)); + expect(scanner.column, equals(2)); + }); + + test('backward through LFs sets the line and column', () { + scanner = LineScanner('foo\nbar\nbaz'); + scanner.expect('foo\nbar\nbaz'); + scanner.position = 2; // "fo" + expect(scanner.line, equals(0)); + expect(scanner.column, equals(2)); + }); + + test('backward through CR LFs sets the line and column', () { + scanner = LineScanner('foo\r\nbar\r\nbaz'); + scanner.expect('foo\r\nbar\r\nbaz'); + scanner.position = 2; // "fo" + expect(scanner.line, equals(0)); + expect(scanner.column, equals(2)); + }); + + test('backward through CR not followed by LFs sets the line and column', + () { + scanner = LineScanner('foo\rbar\rbaz'); + scanner.expect('foo\rbar\rbaz'); + scanner.position = 2; // "fo" + expect(scanner.line, equals(0)); + expect(scanner.column, equals(2)); + }); + + test('backward through CR at end sets the line and column', () { + scanner = LineScanner('foo\rbar\r'); + scanner.expect('foo\rbar\r'); + scanner.position = 2; // "fo" + expect(scanner.line, equals(0)); + expect(scanner.column, equals(2)); + }); + + test('backward through a mix of CR, LF, CR+LF sets the line and column', + () { + scanner = LineScanner('0\n1\r2\r\n3'); + scanner.expect(scanner.string); + + scanner.position = 1; + expect(scanner.line, equals(0)); + expect(scanner.column, equals(1)); + }); + + test('backward through no newlines sets the column', () { + scanner.expect('foo\nbar\r\nbaz'); + scanner.position = 10; // "foo\nbar\r\nb" + expect(scanner.line, equals(2)); + expect(scanner.column, equals(1)); + }); + + test("forward halfway through a CR LF doesn't count as a line", () { + scanner.position = 8; // "foo\nbar\r" + expect(scanner.line, equals(1)); + expect(scanner.column, equals(4)); + }); + + test('forward from halfway through a CR LF counts as a line', () { + scanner.expect('foo\nbar\r'); + scanner.position = 11; // "foo\nbar\r\nba" + expect(scanner.line, equals(2)); + expect(scanner.column, equals(2)); + }); + + test('backward to between CR LF', () { + scanner.expect('foo\nbar\r\nbaz'); + scanner.position = 8; // "foo\nbar\r" + expect(scanner.line, equals(1)); + expect(scanner.column, equals(4)); + }); + + test('backward from between CR LF', () { + scanner.expect('foo\nbar\r'); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(4)); + scanner.position = 5; // "foo\nb" + expect(scanner.line, equals(1)); + expect(scanner.column, equals(1)); + }); + + test('backward to after CR LF', () { + scanner.expect('foo\nbar\r\nbaz'); + scanner.position = 9; // "foo\nbar\r\n" + expect(scanner.line, equals(2)); + expect(scanner.column, equals(0)); + }); + + test('backward to before CR LF', () { + scanner.expect('foo\nbar\r\nbaz'); + scanner.position = 7; // "foo\nbar" + expect(scanner.line, equals(1)); + expect(scanner.column, equals(3)); + }); + }); + + test('state= restores the line, column, and position', () { + scanner.expect('foo\nb'); + final state = scanner.state; + + scanner.scan('ar\nba'); + scanner.state = state; + expect(scanner.rest, equals('ar\r\nbaz')); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(1)); + }); + + test('state= rejects a foreign state', () { + scanner.scan('foo\nb'); + + expect(() => LineScanner(scanner.string).state = scanner.state, + throwsArgumentError); + }); +} diff --git a/pkgs/string_scanner/test/span_scanner_test.dart b/pkgs/string_scanner/test/span_scanner_test.dart new file mode 100644 index 000000000..93d9c477c --- /dev/null +++ b/pkgs/string_scanner/test/span_scanner_test.dart @@ -0,0 +1,238 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:source_span/source_span.dart'; +import 'package:string_scanner/string_scanner.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + testForImplementation( + 'lazy', + ([String? string]) => + SpanScanner(string ?? 'foo\nbar\nbaz', sourceUrl: 'source')); + + testForImplementation( + 'eager', + ([String? string]) => + SpanScanner.eager(string ?? 'foo\nbar\nbaz', sourceUrl: 'source')); + + group('within', () { + const text = 'first\nbefore: foo\nbar\nbaz :after\nlast'; + final startOffset = text.indexOf('foo'); + + late SpanScanner scanner; + setUp(() { + final file = SourceFile.fromString(text, url: 'source'); + scanner = + SpanScanner.within(file.span(startOffset, text.indexOf(' :after'))); + }); + + test('string only includes the span text', () { + expect(scanner.string, equals('foo\nbar\nbaz')); + }); + + test('line and column are span-relative', () { + expect(scanner.line, equals(0)); + expect(scanner.column, equals(0)); + + scanner.scan('foo'); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(3)); + + scanner.scan('\n'); + expect(scanner.line, equals(1)); + expect(scanner.column, equals(0)); + }); + + test('tracks the span for the last match', () { + scanner.scan('fo'); + scanner.scan('o\nba'); + + final span = scanner.lastSpan!; + expect(span.start.offset, equals(startOffset + 2)); + expect(span.start.line, equals(1)); + expect(span.start.column, equals(10)); + expect(span.start.sourceUrl, equals(Uri.parse('source'))); + + expect(span.end.offset, equals(startOffset + 6)); + expect(span.end.line, equals(2)); + expect(span.end.column, equals(2)); + expect(span.start.sourceUrl, equals(Uri.parse('source'))); + + expect(span.text, equals('o\nba')); + }); + + test('.spanFrom() returns a span from a previous state', () { + scanner.scan('fo'); + final state = scanner.state; + scanner.scan('o\nba'); + scanner.scan('r\nba'); + + final span = scanner.spanFrom(state); + expect(span.text, equals('o\nbar\nba')); + }); + + test('.spanFromPosition() returns a span from a previous state', () { + scanner.scan('fo'); + final start = scanner.position; + scanner.scan('o\nba'); + scanner.scan('r\nba'); + + final span = scanner.spanFromPosition(start + 2, start + 5); + expect(span.text, equals('bar')); + }); + + test('.emptySpan returns an empty span at the current location', () { + scanner.scan('foo\nba'); + + final span = scanner.emptySpan; + expect(span.start.offset, equals(startOffset + 6)); + expect(span.start.line, equals(2)); + expect(span.start.column, equals(2)); + expect(span.start.sourceUrl, equals(Uri.parse('source'))); + + expect(span.end.offset, equals(startOffset + 6)); + expect(span.end.line, equals(2)); + expect(span.end.column, equals(2)); + expect(span.start.sourceUrl, equals(Uri.parse('source'))); + + expect(span.text, equals('')); + }); + + test('.error() uses an absolute span', () { + scanner.expect('foo'); + expect( + () => scanner.error('oh no!'), throwsStringScannerException('foo')); + }); + + test('.isDone returns true at the end of the span', () { + scanner.expect('foo\nbar\nbaz'); + expect(scanner.isDone, isTrue); + }); + }); +} + +void testForImplementation( + String name, SpanScanner Function([String string]) create) { + group('for a $name scanner', () { + late SpanScanner scanner; + setUp(() => scanner = create()); + + test('tracks the span for the last match', () { + scanner.scan('fo'); + scanner.scan('o\nba'); + + final span = scanner.lastSpan!; + expect(span.start.offset, equals(2)); + expect(span.start.line, equals(0)); + expect(span.start.column, equals(2)); + expect(span.start.sourceUrl, equals(Uri.parse('source'))); + + expect(span.end.offset, equals(6)); + expect(span.end.line, equals(1)); + expect(span.end.column, equals(2)); + expect(span.start.sourceUrl, equals(Uri.parse('source'))); + + expect(span.text, equals('o\nba')); + }); + + test('.spanFrom() returns a span from a previous state', () { + scanner.scan('fo'); + final state = scanner.state; + scanner.scan('o\nba'); + scanner.scan('r\nba'); + + final span = scanner.spanFrom(state); + expect(span.text, equals('o\nbar\nba')); + }); + + test('.spanFromPosition() returns a span from a previous state', () { + scanner.scan('fo'); + final start = scanner.position; + scanner.scan('o\nba'); + scanner.scan('r\nba'); + + final span = scanner.spanFromPosition(start + 2, start + 5); + expect(span.text, equals('bar')); + }); + + test('.emptySpan returns an empty span at the current location', () { + scanner.scan('foo\nba'); + + final span = scanner.emptySpan; + expect(span.start.offset, equals(6)); + expect(span.start.line, equals(1)); + expect(span.start.column, equals(2)); + expect(span.start.sourceUrl, equals(Uri.parse('source'))); + + expect(span.end.offset, equals(6)); + expect(span.end.line, equals(1)); + expect(span.end.column, equals(2)); + expect(span.start.sourceUrl, equals(Uri.parse('source'))); + + expect(span.text, equals('')); + }); + + group('before a surrogate pair', () { + final codePoint = '\uD83D\uDC6D'.runes.first; + const highSurrogate = 0xD83D; + + late SpanScanner scanner; + setUp(() { + scanner = create('foo: \uD83D\uDC6D bar'); + expect(scanner.scan('foo: '), isTrue); + }); + + test('readChar returns the high surrogate and moves into the pair', () { + expect(scanner.readChar(), equals(highSurrogate)); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(6)); + expect(scanner.position, equals(6)); + }); + + test('readCodePoint returns the code unit and moves past the pair', () { + expect(scanner.readCodePoint(), equals(codePoint)); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(7)); + expect(scanner.position, equals(7)); + }); + + test('scanChar with the high surrogate moves into the pair', () { + expect(scanner.scanChar(highSurrogate), isTrue); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(6)); + expect(scanner.position, equals(6)); + }); + + test('scanChar with the code point moves past the pair', () { + expect(scanner.scanChar(codePoint), isTrue); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(7)); + expect(scanner.position, equals(7)); + }); + + test('expectChar with the high surrogate moves into the pair', () { + scanner.expectChar(highSurrogate); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(6)); + expect(scanner.position, equals(6)); + }); + + test('expectChar with the code point moves past the pair', () { + scanner.expectChar(codePoint); + expect(scanner.line, equals(0)); + expect(scanner.column, equals(7)); + expect(scanner.position, equals(7)); + }); + + test('spanFrom covers the surrogate pair', () { + final state = scanner.state; + scanner.scan('\uD83D\uDC6D b'); + expect(scanner.spanFrom(state).text, equals('\uD83D\uDC6D b')); + }); + }); + }); +} diff --git a/pkgs/string_scanner/test/string_scanner_test.dart b/pkgs/string_scanner/test/string_scanner_test.dart new file mode 100644 index 000000000..36a737e36 --- /dev/null +++ b/pkgs/string_scanner/test/string_scanner_test.dart @@ -0,0 +1,564 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:string_scanner/src/charcode.dart'; +import 'package:string_scanner/string_scanner.dart'; +import 'package:test/test.dart'; + +void main() { + group('with an empty string', () { + late StringScanner scanner; + setUp(() { + scanner = StringScanner(''); + }); + + test('is done', () { + expect(scanner.isDone, isTrue); + expect(scanner.expectDone, isNot(throwsFormatException)); + }); + + test('rest is empty', () { + expect(scanner.rest, isEmpty); + }); + + test('lastMatch is null', () { + expect(scanner.lastMatch, isNull); + }); + + test('position is zero', () { + expect(scanner.position, equals(0)); + }); + + test("readChar fails and doesn't change the state", () { + expect(scanner.readChar, throwsFormatException); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + }); + + test("readCodePoint fails and doesn't change the state", () { + expect(scanner.readCodePoint, throwsFormatException); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + }); + + test("peekChar returns null and doesn't change the state", () { + expect(scanner.peekChar(), isNull); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + }); + + test("peekCodePoint returns null and doesn't change the state", () { + expect(scanner.peekCodePoint(), isNull); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + }); + + test("scanChar returns false and doesn't change the state", () { + expect(scanner.scanChar($f), isFalse); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + }); + + test("expectChar fails and doesn't change the state", () { + expect(() => scanner.expectChar($f), throwsFormatException); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + }); + + test("scan returns false and doesn't change the state", () { + expect(scanner.scan(RegExp('.')), isFalse); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + }); + + test("expect throws a FormatException and doesn't change the state", () { + expect(() => scanner.expect(RegExp('.')), throwsFormatException); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + }); + + test("matches returns false and doesn't change the state", () { + expect(scanner.matches(RegExp('.')), isFalse); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + }); + + test('substring returns the empty string', () { + expect(scanner.substring(0), isEmpty); + }); + + test('setting position to 1 throws an ArgumentError', () { + expect(() { + scanner.position = 1; + }, throwsArgumentError); + }); + + test('setting position to -1 throws an ArgumentError', () { + expect(() { + scanner.position = -1; + }, throwsArgumentError); + }); + }); + + group('at the beginning of a string', () { + late StringScanner scanner; + setUp(() { + scanner = StringScanner('foo bar'); + }); + + test('is not done', () { + expect(scanner.isDone, isFalse); + expect(scanner.expectDone, throwsFormatException); + }); + + test('rest is the whole string', () { + expect(scanner.rest, equals('foo bar')); + }); + + test('lastMatch is null', () { + expect(scanner.lastMatch, isNull); + }); + + test('position is zero', () { + expect(scanner.position, equals(0)); + }); + + test('readChar returns the first character and moves forward', () { + expect(scanner.readChar(), equals(0x66)); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(1)); + }); + + test('readCodePoint returns the first character and moves forward', () { + expect(scanner.readCodePoint(), equals(0x66)); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(1)); + }); + + test('peekChar returns the first character', () { + expect(scanner.peekChar(), equals(0x66)); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + }); + + test('peekChar with an argument returns the nth character', () { + expect(scanner.peekChar(4), equals(0x62)); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + }); + + test('peekCodePoint returns the first character', () { + expect(scanner.peekCodePoint(), equals(0x66)); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + }); + + test('a matching scanChar returns true moves forward', () { + expect(scanner.scanChar($f), isTrue); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(1)); + }); + + test('a non-matching scanChar returns false and does nothing', () { + expect(scanner.scanChar($x), isFalse); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + }); + + test('a matching expectChar moves forward', () { + scanner.expectChar($f); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(1)); + }); + + test('a non-matching expectChar fails', () { + expect(() => scanner.expectChar($x), throwsFormatException); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + }); + + test('a matching scan returns true and changes the state', () { + expect(scanner.scan(RegExp('f(..)')), isTrue); + expect(scanner.lastMatch![1], equals('oo')); + expect(scanner.position, equals(3)); + expect(scanner.rest, equals(' bar')); + }); + + test('a non-matching scan returns false and sets lastMatch to null', () { + expect(scanner.matches(RegExp('f(..)')), isTrue); + expect(scanner.lastMatch, isNotNull); + + expect(scanner.scan(RegExp('b(..)')), isFalse); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + expect(scanner.rest, equals('foo bar')); + }); + + test('a matching expect changes the state', () { + scanner.expect(RegExp('f(..)')); + expect(scanner.lastMatch![1], equals('oo')); + expect(scanner.position, equals(3)); + expect(scanner.rest, equals(' bar')); + }); + + test( + 'a non-matching expect throws a FormatException and sets lastMatch to ' + 'null', () { + expect(scanner.matches(RegExp('f(..)')), isTrue); + expect(scanner.lastMatch, isNotNull); + + expect(() => scanner.expect(RegExp('b(..)')), throwsFormatException); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + expect(scanner.rest, equals('foo bar')); + }); + + test('a matching matches returns true and only changes lastMatch', () { + expect(scanner.matches(RegExp('f(..)')), isTrue); + expect(scanner.lastMatch![1], equals('oo')); + expect(scanner.position, equals(0)); + expect(scanner.rest, equals('foo bar')); + }); + + test("a non-matching matches returns false and doesn't change the state", + () { + expect(scanner.matches(RegExp('b(..)')), isFalse); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(0)); + expect(scanner.rest, equals('foo bar')); + }); + + test('substring from the beginning returns the empty string', () { + expect(scanner.substring(0), isEmpty); + }); + + test('substring with a custom end returns the substring', () { + expect(scanner.substring(0, 3), equals('foo')); + }); + + test('substring with the string length returns the whole string', () { + expect(scanner.substring(0, 7), equals('foo bar')); + }); + + test('setting position to 1 moves the cursor forward', () { + scanner.position = 1; + expect(scanner.position, equals(1)); + expect(scanner.rest, equals('oo bar')); + + expect(scanner.scan(RegExp('oo.')), isTrue); + expect(scanner.lastMatch![0], equals('oo ')); + expect(scanner.position, equals(4)); + expect(scanner.rest, equals('bar')); + }); + + test('setting position beyond the string throws an ArgumentError', () { + expect(() { + scanner.position = 8; + }, throwsArgumentError); + }); + + test('setting position to -1 throws an ArgumentError', () { + expect(() { + scanner.position = -1; + }, throwsArgumentError); + }); + + test('scan accepts any Pattern', () { + expect(scanner.scan('foo'), isTrue); + expect(scanner.lastMatch![0], equals('foo')); + expect(scanner.position, equals(3)); + expect(scanner.rest, equals(' bar')); + }); + + test('scans multiple times', () { + expect(scanner.scan(RegExp('f(..)')), isTrue); + expect(scanner.lastMatch![1], equals('oo')); + expect(scanner.position, equals(3)); + expect(scanner.rest, equals(' bar')); + + expect(scanner.scan(RegExp(' b(..)')), isTrue); + expect(scanner.lastMatch![1], equals('ar')); + expect(scanner.position, equals(7)); + expect(scanner.rest, equals('')); + expect(scanner.isDone, isTrue); + expect(scanner.expectDone, isNot(throwsFormatException)); + }); + }); + + group('after a scan', () { + late StringScanner scanner; + setUp(() { + scanner = StringScanner('foo bar'); + expect(scanner.scan('foo'), isTrue); + }); + + test('readChar returns the first character and unsets the last match', () { + expect(scanner.readChar(), equals($space)); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(4)); + }); + + test('readCodePoint returns the first character and unsets the last match', + () { + expect(scanner.readCodePoint(), equals($space)); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(4)); + }); + + test('a matching scanChar returns true and unsets the last match', () { + expect(scanner.scanChar($space), isTrue); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(4)); + }); + + test('a matching expectChar returns true and unsets the last match', () { + scanner.expectChar($space); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(4)); + }); + }); + + group('at the end of a string', () { + late StringScanner scanner; + setUp(() { + scanner = StringScanner('foo bar'); + expect(scanner.scan('foo bar'), isTrue); + }); + + test('is done', () { + expect(scanner.isDone, isTrue); + expect(scanner.expectDone, isNot(throwsFormatException)); + }); + + test('rest is empty', () { + expect(scanner.rest, isEmpty); + }); + + test('position is zero', () { + expect(scanner.position, equals(7)); + }); + + test("readChar fails and doesn't change the state", () { + expect(scanner.readChar, throwsFormatException); + expect(scanner.lastMatch, isNotNull); + expect(scanner.position, equals(7)); + }); + + test("readCodePoint fails and doesn't change the state", () { + expect(scanner.readCodePoint, throwsFormatException); + expect(scanner.lastMatch, isNotNull); + expect(scanner.position, equals(7)); + }); + + test("peekChar returns null and doesn't change the state", () { + expect(scanner.peekChar(), isNull); + expect(scanner.lastMatch, isNotNull); + expect(scanner.position, equals(7)); + }); + + test("peekCodePoint returns null and doesn't change the state", () { + expect(scanner.peekCodePoint(), isNull); + expect(scanner.lastMatch, isNotNull); + expect(scanner.position, equals(7)); + }); + + test("scanChar returns false and doesn't change the state", () { + expect(scanner.scanChar($f), isFalse); + expect(scanner.lastMatch, isNotNull); + expect(scanner.position, equals(7)); + }); + + test("expectChar fails and doesn't change the state", () { + expect(() => scanner.expectChar($f), throwsFormatException); + expect(scanner.lastMatch, isNotNull); + expect(scanner.position, equals(7)); + }); + + test('scan returns false and sets lastMatch to null', () { + expect(scanner.scan(RegExp('.')), isFalse); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(7)); + }); + + test('expect throws a FormatException and sets lastMatch to null', () { + expect(() => scanner.expect(RegExp('.')), throwsFormatException); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(7)); + }); + + test('matches returns false sets lastMatch to null', () { + expect(scanner.matches(RegExp('.')), isFalse); + expect(scanner.lastMatch, isNull); + expect(scanner.position, equals(7)); + }); + + test('substring from the beginning returns the whole string', () { + expect(scanner.substring(0), equals('foo bar')); + }); + + test('substring with a custom start returns a substring from there', () { + expect(scanner.substring(4), equals('bar')); + }); + + test('substring with a custom start and end returns that substring', () { + expect(scanner.substring(3, 5), equals(' b')); + }); + + test('setting position to 1 moves the cursor backward', () { + scanner.position = 1; + expect(scanner.position, equals(1)); + expect(scanner.rest, equals('oo bar')); + + expect(scanner.scan(RegExp('oo.')), isTrue); + expect(scanner.lastMatch![0], equals('oo ')); + expect(scanner.position, equals(4)); + expect(scanner.rest, equals('bar')); + }); + + test('setting and resetting position clears lastMatch', () { + final oldPosition = scanner.position; + scanner.position = 1; + scanner.position = oldPosition; + expect(scanner.lastMatch, isNull); + }); + + test('setting position beyond the string throws an ArgumentError', () { + expect(() { + scanner.position = 8; + }, throwsArgumentError); + }); + + test('setting position to -1 throws an ArgumentError', () { + expect(() { + scanner.position = -1; + }, throwsArgumentError); + }); + }); + + group('before a surrogate pair', () { + final codePoint = '\uD83D\uDC6D'.runes.first; + const highSurrogate = 0xD83D; + + late StringScanner scanner; + setUp(() { + scanner = StringScanner('foo: \uD83D\uDC6D'); + expect(scanner.scan('foo: '), isTrue); + }); + + test('readChar returns the high surrogate and moves into the pair', () { + expect(scanner.readChar(), equals(highSurrogate)); + expect(scanner.position, equals(6)); + }); + + test('readCodePoint returns the code unit and moves past the pair', () { + expect(scanner.readCodePoint(), equals(codePoint)); + expect(scanner.position, equals(7)); + }); + + test('peekChar returns the high surrogate', () { + expect(scanner.peekChar(), equals(highSurrogate)); + expect(scanner.position, equals(5)); + }); + + test('peekCodePoint returns the code unit', () { + expect(scanner.peekCodePoint(), equals(codePoint)); + expect(scanner.position, equals(5)); + }); + + test('scanChar with the high surrogate moves into the pair', () { + expect(scanner.scanChar(highSurrogate), isTrue); + expect(scanner.position, equals(6)); + }); + + test('scanChar with the code point moves past the pair', () { + expect(scanner.scanChar(codePoint), isTrue); + expect(scanner.position, equals(7)); + }); + + test('expectChar with the high surrogate moves into the pair', () { + scanner.expectChar(highSurrogate); + expect(scanner.position, equals(6)); + }); + + test('expectChar with the code point moves past the pair', () { + scanner.expectChar(codePoint); + expect(scanner.position, equals(7)); + }); + }); + + group('before an invalid surrogate pair', () { + // This surrogate pair is invalid because U+E000 is just outside the range + // of low surrogates. If it were interpreted as a surrogate pair anyway, the + // value would be U+110000, which is outside of the Unicode gamut. + const codePoint = 0x110000; + const highSurrogate = 0xD800; + + late StringScanner scanner; + setUp(() { + scanner = StringScanner('foo: \uD800\uE000'); + expect(scanner.scan('foo: '), isTrue); + }); + + test('readChar returns the high surrogate and moves into the pair', () { + expect(scanner.readChar(), equals(highSurrogate)); + expect(scanner.position, equals(6)); + }); + + test('readCodePoint returns the high surrogate and moves past the pair', + () { + expect(scanner.readCodePoint(), equals(highSurrogate)); + expect(scanner.position, equals(6)); + }); + + test('peekChar returns the high surrogate', () { + expect(scanner.peekChar(), equals(highSurrogate)); + expect(scanner.position, equals(5)); + }); + + test('peekCodePoint returns the high surrogate', () { + expect(scanner.peekCodePoint(), equals(highSurrogate)); + expect(scanner.position, equals(5)); + }); + + test('scanChar with the high surrogate moves into the pair', () { + expect(scanner.scanChar(highSurrogate), isTrue); + expect(scanner.position, equals(6)); + }); + + test('scanChar with the fake code point returns false', () { + expect(scanner.scanChar(codePoint), isFalse); + expect(scanner.position, equals(5)); + }); + + test('expectChar with the high surrogate moves into the pair', () { + scanner.expectChar(highSurrogate); + expect(scanner.position, equals(6)); + }); + + test('expectChar with the fake code point fails', () { + expect(() => scanner.expectChar(codePoint), throwsRangeError); + }); + }); + + group('a scanner constructed with a custom position', () { + test('starts scanning from that position', () { + final scanner = StringScanner('foo bar', position: 1); + expect(scanner.position, equals(1)); + expect(scanner.rest, equals('oo bar')); + + expect(scanner.scan(RegExp('oo.')), isTrue); + expect(scanner.lastMatch![0], equals('oo ')); + expect(scanner.position, equals(4)); + expect(scanner.rest, equals('bar')); + }); + + test('throws an ArgumentError if the position is -1', () { + expect(() => StringScanner('foo bar', position: -1), throwsArgumentError); + }); + + test('throws an ArgumentError if the position is beyond the string', () { + expect(() => StringScanner('foo bar', position: 8), throwsArgumentError); + }); + }); +} diff --git a/pkgs/string_scanner/test/utils.dart b/pkgs/string_scanner/test/utils.dart new file mode 100644 index 000000000..ca03c064f --- /dev/null +++ b/pkgs/string_scanner/test/utils.dart @@ -0,0 +1,12 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:string_scanner/string_scanner.dart'; +import 'package:test/test.dart'; + +/// Returns a matcher that asserts that a closure throws a +/// [StringScannerException] with the given [text]. +Matcher throwsStringScannerException(String text) => + throwsA(const TypeMatcher() + .having((e) => e.span!.text, 'span.text', text)); diff --git a/pkgs/term_glyph/.gitignore b/pkgs/term_glyph/.gitignore new file mode 100644 index 000000000..01d42c084 --- /dev/null +++ b/pkgs/term_glyph/.gitignore @@ -0,0 +1,4 @@ +.dart_tool/ +.pub/ +.packages +pubspec.lock diff --git a/pkgs/term_glyph/AUTHORS b/pkgs/term_glyph/AUTHORS new file mode 100644 index 000000000..e8063a8cd --- /dev/null +++ b/pkgs/term_glyph/AUTHORS @@ -0,0 +1,6 @@ +# Below is a list of people and organizations that have contributed +# to the project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. diff --git a/pkgs/term_glyph/CHANGELOG.md b/pkgs/term_glyph/CHANGELOG.md new file mode 100644 index 000000000..b7359cfb7 --- /dev/null +++ b/pkgs/term_glyph/CHANGELOG.md @@ -0,0 +1,31 @@ +## 1.2.2 + +* Require Dart 3.1 +* Move to `dart-lang/tools` monorepo. + +## 1.2.1 + +* Migrate to `package:lints`. +* Populate the pubspec `repository` field. + +## 1.2.0 + +* Stable release for null safety. +* Update SDK constraints to `>=2.12.0-0 <3.0.0` based on beta release + guidelines. + +## 1.1.0 + +* Add a `GlyphSet` class that can be used to easily choose which set of glyphs + to use for a particular chunk of code. + +* Add `asciiGlyphs`, `unicodeGlyphs`, and `glyphs` getters that provide access + to `GlyphSet`s. + +## 1.0.1 + +* Set max SDK version to `<3.0.0`. + +## 1.0.0 + +* Initial version. diff --git a/pkgs/term_glyph/LICENSE b/pkgs/term_glyph/LICENSE new file mode 100644 index 000000000..03af64abe --- /dev/null +++ b/pkgs/term_glyph/LICENSE @@ -0,0 +1,27 @@ +Copyright 2017, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/term_glyph/README.md b/pkgs/term_glyph/README.md new file mode 100644 index 000000000..75039aaeb --- /dev/null +++ b/pkgs/term_glyph/README.md @@ -0,0 +1,47 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/term_glyph.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/term_glyph.yaml) +[![pub package](https://img.shields.io/pub/v/term_glyph.svg)](https://pub.dev/packages/term_glyph) +[![package publisher](https://img.shields.io/pub/publisher/term_glyph.svg)](https://pub.dev/packages/term_glyph/publisher) + +This library contains getters for useful Unicode glyphs as well as plain ASCII +alternatives. It's intended to be used in command-line applications that may run +in places where Unicode isn't well-supported and libraries that may be used by +those applications. + +We recommend that you import this library with the prefix "glyph". For example: + +```dart +import 'package:term_glyph/term_glyph.dart' as glyph; + +/// Formats [items] into a bulleted list, with one item per line. +String bulletedList(List items) => + items.map((item) => "${glyph.bullet} $item").join("\n"); +``` + +## ASCII Mode + +Some shells are unable to display Unicode characters, so this package is able to +transparently switch its glyphs to ASCII alternatives by setting [the `ascii` +attribute][ascii]. When this attribute is `true`, all glyphs use ASCII +characters instead. It currently defaults to `false`, although in the future it +may default to `true` for applications running on the Dart VM on Windows. For +example: + +[ascii]: https://pub.dev/documentation/term_glyph/latest/term_glyph/ascii.html + +```dart +import 'dart:io'; + +import 'package:term_glyph/term_glyph.dart' as glyph; + +void main() { + glyph.ascii = Platform.isWindows; + + // Prints "Unicode => ASCII" on Windows, "Unicode ━▶ ASCII" everywhere else. + print("Unicode ${glyph.rightArrow} ASCII"); +} +``` + +All ASCII glyphs are guaranteed to be the same number of characters as the +corresponding Unicode glyphs, so that they line up properly when printed on a +terminal. The specific ASCII text for a given Unicode glyph may change over +time; this is not considered a breaking change. diff --git a/pkgs/term_glyph/analysis_options.yaml b/pkgs/term_glyph/analysis_options.yaml new file mode 100644 index 000000000..6d74ee93f --- /dev/null +++ b/pkgs/term_glyph/analysis_options.yaml @@ -0,0 +1,32 @@ +# https://dart.dev/guides/language/analysis-options +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + - avoid_bool_literals_in_conditional_expressions + - avoid_classes_with_only_static_members + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_returning_this + - avoid_unused_constructor_parameters + - avoid_void_async + - cancel_subscriptions + - join_return_with_assignment + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_runtimeType_toString + - prefer_const_declarations + - prefer_expression_function_bodies + - prefer_final_locals + - unnecessary_await_in_return + - unnecessary_breaks + - use_if_null_to_convert_nulls_to_bools + - use_raw_strings + - use_string_buffers diff --git a/pkgs/term_glyph/data.csv b/pkgs/term_glyph/data.csv new file mode 100644 index 000000000..92a72f7bc --- /dev/null +++ b/pkgs/term_glyph/data.csv @@ -0,0 +1,85 @@ +# Miscellaneous +bullet,•,*,A bullet point. + +# Arrows +leftArrow,←,<,"A left-pointing arrow. + +Note that the Unicode arrow glyphs may overlap with adjacent characters in some +terminal fonts, and should generally be surrounding by spaces." +rightArrow,→,>,"A right-pointing arrow. + +Note that the Unicode arrow glyphs may overlap with adjacent characters in some +terminal fonts, and should generally be surrounding by spaces." +upArrow,↑,^,An upwards-pointing arrow. +downArrow,↓,v,A downwards-pointing arrow. +longLeftArrow,◀━,<=,A two-character left-pointing arrow. +longRightArrow,━▶,=>,A two-character right-pointing arrow. + +# Box drawing characters + +## Normal +horizontalLine,─,-,A horizontal line that can be used to draw a box. +verticalLine,│,|,A vertical line that can be used to draw a box. +topLeftCorner,┌,",",The upper left-hand corner of a box. +topRightCorner,┐,",",The upper right-hand corner of a box. +bottomLeftCorner,└,',The lower left-hand corner of a box. +bottomRightCorner,┘,',The lower right-hand corner of a box. +cross,┼,+,An intersection of vertical and horizontal box lines. +teeUp,┴,+,A horizontal box line with a vertical line going up from the middle. +teeDown,┬,+,A horizontal box line with a vertical line going down from the middle. +teeLeft,┤,+,A vertical box line with a horizontal line going left from the middle. +teeRight,├,+,A vertical box line with a horizontal line going right from the middle. +upEnd,╵,',The top half of a vertical box line. +downEnd,╷,",",The bottom half of a vertical box line. +leftEnd,╴,-,The left half of a horizontal box line. +rightEnd,╶,-,The right half of a horizontal box line. + +## Bold +horizontalLineBold,━,=,A bold horizontal line that can be used to draw a box. +verticalLineBold,┃,|,A bold vertical line that can be used to draw a box. +topLeftCornerBold,┏,",",The bold upper left-hand corner of a box. +topRightCornerBold,┓,",",The bold upper right-hand corner of a box. +bottomLeftCornerBold,┗,',The bold lower left-hand corner of a box. +bottomRightCornerBold,┛,',The bold lower right-hand corner of a box. +crossBold,╋,+,An intersection of bold vertical and horizontal box lines. +teeUpBold,┻,+,A bold horizontal box line with a vertical line going up from the middle. +teeDownBold,┳,+,A bold horizontal box line with a vertical line going down from the middle. +teeLeftBold,┫,+,A bold vertical box line with a horizontal line going left from the middle. +teeRightBold,┣,+,A bold vertical box line with a horizontal line going right from the middle. +upEndBold,╹,',The top half of a bold vertical box line. +downEndBold,╻,",",The bottom half of a bold vertical box line. +leftEndBold,╸,-,The left half of a bold horizontal box line. +rightEndBold,╺,-,The right half of a bold horizontal box line. + +## Double +horizontalLineDouble,═,=,A double horizontal line that can be used to draw a box. +verticalLineDouble,║,|,A double vertical line that can be used to draw a box. +topLeftCornerDouble,╔,",",The double upper left-hand corner of a box. +topRightCornerDouble,╗,",",The double upper right-hand corner of a box. +bottomLeftCornerDouble,╚,"""",The double lower left-hand corner of a box. +bottomRightCornerDouble,╝,"""",The double lower right-hand corner of a box. +crossDouble,╬,+,An intersection of double vertical and horizontal box lines. +teeUpDouble,╩,+,A double horizontal box line with a vertical line going up from the middle. +teeDownDouble,╦,+,A double horizontal box line with a vertical line going down from the middle. +teeLeftDouble,╣,+,A double vertical box line with a horizontal line going left from the middle. +teeRightDouble,╠,+,A double vertical box line with a horizontal line going right from the middle. + +## Dashed + +### Double +horizontalLineDoubleDash,╌,-,A dashed horizontal line that can be used to draw a box. +horizontalLineDoubleDashBold,╍,-,A bold dashed horizontal line that can be used to draw a box. +verticalLineDoubleDash,╎,|,A dashed vertical line that can be used to draw a box. +verticalLineDoubleDashBold,╏,|,A bold dashed vertical line that can be used to draw a box. + +### Triple +horizontalLineTripleDash,┄,-,A dashed horizontal line that can be used to draw a box. +horizontalLineTripleDashBold,┅,-,A bold dashed horizontal line that can be used to draw a box. +verticalLineTripleDash,┆,|,A dashed vertical line that can be used to draw a box. +verticalLineTripleDashBold,┇,|,A bold dashed vertical line that can be used to draw a box. + +### Quadruple +horizontalLineQuadrupleDash,┈,-,A dashed horizontal line that can be used to draw a box. +horizontalLineQuadrupleDashBold,┉,-,A bold dashed horizontal line that can be used to draw a box. +verticalLineQuadrupleDash,┊,|,A dashed vertical line that can be used to draw a box. +verticalLineQuadrupleDashBold,┋,|,A bold dashed vertical line that can be used to draw a box. diff --git a/pkgs/term_glyph/lib/src/generated/ascii_glyph_set.dart b/pkgs/term_glyph/lib/src/generated/ascii_glyph_set.dart new file mode 100644 index 000000000..7c97d7f29 --- /dev/null +++ b/pkgs/term_glyph/lib/src/generated/ascii_glyph_set.dart @@ -0,0 +1,137 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// Don't modify this file by hand! It's generated by tool/generate.dart. + +import 'glyph_set.dart'; + +/// A [GlyphSet] that includes only ASCII glyphs. +class AsciiGlyphSet implements GlyphSet { + const AsciiGlyphSet(); + + /// Returns [glyph] if `this` supports Unicode glyphs and [alternative] + /// otherwise. + @override + String glyphOrAscii(String glyph, String alternative) => alternative; + @override + String get bullet => '*'; + @override + String get leftArrow => '<'; + @override + String get rightArrow => '>'; + @override + String get upArrow => '^'; + @override + String get downArrow => 'v'; + @override + String get longLeftArrow => '<='; + @override + String get longRightArrow => '=>'; + @override + String get horizontalLine => '-'; + @override + String get verticalLine => '|'; + @override + String get topLeftCorner => ','; + @override + String get topRightCorner => ','; + @override + String get bottomLeftCorner => "'"; + @override + String get bottomRightCorner => "'"; + @override + String get cross => '+'; + @override + String get teeUp => '+'; + @override + String get teeDown => '+'; + @override + String get teeLeft => '+'; + @override + String get teeRight => '+'; + @override + String get upEnd => "'"; + @override + String get downEnd => ','; + @override + String get leftEnd => '-'; + @override + String get rightEnd => '-'; + @override + String get horizontalLineBold => '='; + @override + String get verticalLineBold => '|'; + @override + String get topLeftCornerBold => ','; + @override + String get topRightCornerBold => ','; + @override + String get bottomLeftCornerBold => "'"; + @override + String get bottomRightCornerBold => "'"; + @override + String get crossBold => '+'; + @override + String get teeUpBold => '+'; + @override + String get teeDownBold => '+'; + @override + String get teeLeftBold => '+'; + @override + String get teeRightBold => '+'; + @override + String get upEndBold => "'"; + @override + String get downEndBold => ','; + @override + String get leftEndBold => '-'; + @override + String get rightEndBold => '-'; + @override + String get horizontalLineDouble => '='; + @override + String get verticalLineDouble => '|'; + @override + String get topLeftCornerDouble => ','; + @override + String get topRightCornerDouble => ','; + @override + String get bottomLeftCornerDouble => '"'; + @override + String get bottomRightCornerDouble => '"'; + @override + String get crossDouble => '+'; + @override + String get teeUpDouble => '+'; + @override + String get teeDownDouble => '+'; + @override + String get teeLeftDouble => '+'; + @override + String get teeRightDouble => '+'; + @override + String get horizontalLineDoubleDash => '-'; + @override + String get horizontalLineDoubleDashBold => '-'; + @override + String get verticalLineDoubleDash => '|'; + @override + String get verticalLineDoubleDashBold => '|'; + @override + String get horizontalLineTripleDash => '-'; + @override + String get horizontalLineTripleDashBold => '-'; + @override + String get verticalLineTripleDash => '|'; + @override + String get verticalLineTripleDashBold => '|'; + @override + String get horizontalLineQuadrupleDash => '-'; + @override + String get horizontalLineQuadrupleDashBold => '-'; + @override + String get verticalLineQuadrupleDash => '|'; + @override + String get verticalLineQuadrupleDashBold => '|'; +} diff --git a/pkgs/term_glyph/lib/src/generated/glyph_set.dart b/pkgs/term_glyph/lib/src/generated/glyph_set.dart new file mode 100644 index 000000000..be1a668a1 --- /dev/null +++ b/pkgs/term_glyph/lib/src/generated/glyph_set.dart @@ -0,0 +1,227 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// Don't modify this file by hand! It's generated by tool/generate.dart. + +/// A class that provides access to every configurable glyph. +/// +/// This is provided as a class so that individual chunks of code can choose +/// between `ascii` and `unicode` glyphs. For example: +/// +/// ```dart +/// import 'package:term_glyph/term_glyph.dart' as glyph; +/// +/// /// Adds a vertical line to the left of [text]. +/// /// +/// /// If [unicode] is `true`, this uses Unicode for the line. If it's +/// /// `false`, this uses plain ASCII characters. If it's `null`, it +/// /// defaults to [glyph.ascii]. +/// void addVerticalLine(String text, {bool unicode}) { +/// var glyphs = +/// (unicode ?? !glyph.ascii) ? glyph.unicodeGlyphs : glyph.asciiGlyphs; +/// +/// return text +/// .split('\n') +/// .map((line) => '${glyphs.verticalLine} $line') +/// .join('\n'); +/// } +/// ``` +abstract class GlyphSet { + /// Returns [glyph] if `this` supports Unicode glyphs and [alternative] + /// otherwise. + String glyphOrAscii(String glyph, String alternative); + + /// A bullet point. + String get bullet; + + /// A left-pointing arrow. + /// + /// Note that the Unicode arrow glyphs may overlap with adjacent characters in + /// some terminal fonts, and should generally be surrounding by spaces. + String get leftArrow; + + /// A right-pointing arrow. + /// + /// Note that the Unicode arrow glyphs may overlap with adjacent characters in + /// some terminal fonts, and should generally be surrounding by spaces. + String get rightArrow; + + /// An upwards-pointing arrow. + String get upArrow; + + /// A downwards-pointing arrow. + String get downArrow; + + /// A two-character left-pointing arrow. + String get longLeftArrow; + + /// A two-character right-pointing arrow. + String get longRightArrow; + + /// A horizontal line that can be used to draw a box. + String get horizontalLine; + + /// A vertical line that can be used to draw a box. + String get verticalLine; + + /// The upper left-hand corner of a box. + String get topLeftCorner; + + /// The upper right-hand corner of a box. + String get topRightCorner; + + /// The lower left-hand corner of a box. + String get bottomLeftCorner; + + /// The lower right-hand corner of a box. + String get bottomRightCorner; + + /// An intersection of vertical and horizontal box lines. + String get cross; + + /// A horizontal box line with a vertical line going up from the middle. + String get teeUp; + + /// A horizontal box line with a vertical line going down from the middle. + String get teeDown; + + /// A vertical box line with a horizontal line going left from the middle. + String get teeLeft; + + /// A vertical box line with a horizontal line going right from the middle. + String get teeRight; + + /// The top half of a vertical box line. + String get upEnd; + + /// The bottom half of a vertical box line. + String get downEnd; + + /// The left half of a horizontal box line. + String get leftEnd; + + /// The right half of a horizontal box line. + String get rightEnd; + + /// A bold horizontal line that can be used to draw a box. + String get horizontalLineBold; + + /// A bold vertical line that can be used to draw a box. + String get verticalLineBold; + + /// The bold upper left-hand corner of a box. + String get topLeftCornerBold; + + /// The bold upper right-hand corner of a box. + String get topRightCornerBold; + + /// The bold lower left-hand corner of a box. + String get bottomLeftCornerBold; + + /// The bold lower right-hand corner of a box. + String get bottomRightCornerBold; + + /// An intersection of bold vertical and horizontal box lines. + String get crossBold; + + /// A bold horizontal box line with a vertical line going up from the middle. + String get teeUpBold; + + /// A bold horizontal box line with a vertical line going down from the + /// middle. + String get teeDownBold; + + /// A bold vertical box line with a horizontal line going left from the + /// middle. + String get teeLeftBold; + + /// A bold vertical box line with a horizontal line going right from the + /// middle. + String get teeRightBold; + + /// The top half of a bold vertical box line. + String get upEndBold; + + /// The bottom half of a bold vertical box line. + String get downEndBold; + + /// The left half of a bold horizontal box line. + String get leftEndBold; + + /// The right half of a bold horizontal box line. + String get rightEndBold; + + /// A double horizontal line that can be used to draw a box. + String get horizontalLineDouble; + + /// A double vertical line that can be used to draw a box. + String get verticalLineDouble; + + /// The double upper left-hand corner of a box. + String get topLeftCornerDouble; + + /// The double upper right-hand corner of a box. + String get topRightCornerDouble; + + /// The double lower left-hand corner of a box. + String get bottomLeftCornerDouble; + + /// The double lower right-hand corner of a box. + String get bottomRightCornerDouble; + + /// An intersection of double vertical and horizontal box lines. + String get crossDouble; + + /// A double horizontal box line with a vertical line going up from the + /// middle. + String get teeUpDouble; + + /// A double horizontal box line with a vertical line going down from the + /// middle. + String get teeDownDouble; + + /// A double vertical box line with a horizontal line going left from the + /// middle. + String get teeLeftDouble; + + /// A double vertical box line with a horizontal line going right from the + /// middle. + String get teeRightDouble; + + /// A dashed horizontal line that can be used to draw a box. + String get horizontalLineDoubleDash; + + /// A bold dashed horizontal line that can be used to draw a box. + String get horizontalLineDoubleDashBold; + + /// A dashed vertical line that can be used to draw a box. + String get verticalLineDoubleDash; + + /// A bold dashed vertical line that can be used to draw a box. + String get verticalLineDoubleDashBold; + + /// A dashed horizontal line that can be used to draw a box. + String get horizontalLineTripleDash; + + /// A bold dashed horizontal line that can be used to draw a box. + String get horizontalLineTripleDashBold; + + /// A dashed vertical line that can be used to draw a box. + String get verticalLineTripleDash; + + /// A bold dashed vertical line that can be used to draw a box. + String get verticalLineTripleDashBold; + + /// A dashed horizontal line that can be used to draw a box. + String get horizontalLineQuadrupleDash; + + /// A bold dashed horizontal line that can be used to draw a box. + String get horizontalLineQuadrupleDashBold; + + /// A dashed vertical line that can be used to draw a box. + String get verticalLineQuadrupleDash; + + /// A bold dashed vertical line that can be used to draw a box. + String get verticalLineQuadrupleDashBold; +} diff --git a/pkgs/term_glyph/lib/src/generated/top_level.dart b/pkgs/term_glyph/lib/src/generated/top_level.dart new file mode 100644 index 000000000..925903eef --- /dev/null +++ b/pkgs/term_glyph/lib/src/generated/top_level.dart @@ -0,0 +1,383 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// Don't modify this file by hand! It's generated by tool/generate.dart. + +import '../../term_glyph.dart' as glyph; + +/// A bullet point. +/// +/// If [glyph.ascii] is `false`, this is "•". If it's `true`, this is +/// "*" instead. +String get bullet => glyph.glyphs.bullet; + +/// A left-pointing arrow. +/// +/// Note that the Unicode arrow glyphs may overlap with adjacent characters in +/// some terminal fonts, and should generally be surrounding by spaces. +/// +/// If [glyph.ascii] is `false`, this is "←". If it's `true`, this is +/// "<" instead. +String get leftArrow => glyph.glyphs.leftArrow; + +/// A right-pointing arrow. +/// +/// Note that the Unicode arrow glyphs may overlap with adjacent characters in +/// some terminal fonts, and should generally be surrounding by spaces. +/// +/// If [glyph.ascii] is `false`, this is "→". If it's `true`, this is +/// ">" instead. +String get rightArrow => glyph.glyphs.rightArrow; + +/// An upwards-pointing arrow. +/// +/// If [glyph.ascii] is `false`, this is "↑". If it's `true`, this is +/// "^" instead. +String get upArrow => glyph.glyphs.upArrow; + +/// A downwards-pointing arrow. +/// +/// If [glyph.ascii] is `false`, this is "↓". If it's `true`, this is +/// "v" instead. +String get downArrow => glyph.glyphs.downArrow; + +/// A two-character left-pointing arrow. +/// +/// If [glyph.ascii] is `false`, this is "◀━". If it's `true`, this is +/// "<=" instead. +String get longLeftArrow => glyph.glyphs.longLeftArrow; + +/// A two-character right-pointing arrow. +/// +/// If [glyph.ascii] is `false`, this is "━▶". If it's `true`, this is +/// "=>" instead. +String get longRightArrow => glyph.glyphs.longRightArrow; + +/// A horizontal line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "─". If it's `true`, this is +/// "-" instead. +String get horizontalLine => glyph.glyphs.horizontalLine; + +/// A vertical line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "│". If it's `true`, this is +/// "|" instead. +String get verticalLine => glyph.glyphs.verticalLine; + +/// The upper left-hand corner of a box. +/// +/// If [glyph.ascii] is `false`, this is "┌". If it's `true`, this is +/// "," instead. +String get topLeftCorner => glyph.glyphs.topLeftCorner; + +/// The upper right-hand corner of a box. +/// +/// If [glyph.ascii] is `false`, this is "┐". If it's `true`, this is +/// "," instead. +String get topRightCorner => glyph.glyphs.topRightCorner; + +/// The lower left-hand corner of a box. +/// +/// If [glyph.ascii] is `false`, this is "└". If it's `true`, this is +/// "'" instead. +String get bottomLeftCorner => glyph.glyphs.bottomLeftCorner; + +/// The lower right-hand corner of a box. +/// +/// If [glyph.ascii] is `false`, this is "┘". If it's `true`, this is +/// "'" instead. +String get bottomRightCorner => glyph.glyphs.bottomRightCorner; + +/// An intersection of vertical and horizontal box lines. +/// +/// If [glyph.ascii] is `false`, this is "┼". If it's `true`, this is +/// "+" instead. +String get cross => glyph.glyphs.cross; + +/// A horizontal box line with a vertical line going up from the middle. +/// +/// If [glyph.ascii] is `false`, this is "┴". If it's `true`, this is +/// "+" instead. +String get teeUp => glyph.glyphs.teeUp; + +/// A horizontal box line with a vertical line going down from the middle. +/// +/// If [glyph.ascii] is `false`, this is "┬". If it's `true`, this is +/// "+" instead. +String get teeDown => glyph.glyphs.teeDown; + +/// A vertical box line with a horizontal line going left from the middle. +/// +/// If [glyph.ascii] is `false`, this is "┤". If it's `true`, this is +/// "+" instead. +String get teeLeft => glyph.glyphs.teeLeft; + +/// A vertical box line with a horizontal line going right from the middle. +/// +/// If [glyph.ascii] is `false`, this is "├". If it's `true`, this is +/// "+" instead. +String get teeRight => glyph.glyphs.teeRight; + +/// The top half of a vertical box line. +/// +/// If [glyph.ascii] is `false`, this is "╵". If it's `true`, this is +/// "'" instead. +String get upEnd => glyph.glyphs.upEnd; + +/// The bottom half of a vertical box line. +/// +/// If [glyph.ascii] is `false`, this is "╷". If it's `true`, this is +/// "," instead. +String get downEnd => glyph.glyphs.downEnd; + +/// The left half of a horizontal box line. +/// +/// If [glyph.ascii] is `false`, this is "╴". If it's `true`, this is +/// "-" instead. +String get leftEnd => glyph.glyphs.leftEnd; + +/// The right half of a horizontal box line. +/// +/// If [glyph.ascii] is `false`, this is "╶". If it's `true`, this is +/// "-" instead. +String get rightEnd => glyph.glyphs.rightEnd; + +/// A bold horizontal line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "━". If it's `true`, this is +/// "=" instead. +String get horizontalLineBold => glyph.glyphs.horizontalLineBold; + +/// A bold vertical line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "┃". If it's `true`, this is +/// "|" instead. +String get verticalLineBold => glyph.glyphs.verticalLineBold; + +/// The bold upper left-hand corner of a box. +/// +/// If [glyph.ascii] is `false`, this is "┏". If it's `true`, this is +/// "," instead. +String get topLeftCornerBold => glyph.glyphs.topLeftCornerBold; + +/// The bold upper right-hand corner of a box. +/// +/// If [glyph.ascii] is `false`, this is "┓". If it's `true`, this is +/// "," instead. +String get topRightCornerBold => glyph.glyphs.topRightCornerBold; + +/// The bold lower left-hand corner of a box. +/// +/// If [glyph.ascii] is `false`, this is "┗". If it's `true`, this is +/// "'" instead. +String get bottomLeftCornerBold => glyph.glyphs.bottomLeftCornerBold; + +/// The bold lower right-hand corner of a box. +/// +/// If [glyph.ascii] is `false`, this is "┛". If it's `true`, this is +/// "'" instead. +String get bottomRightCornerBold => glyph.glyphs.bottomRightCornerBold; + +/// An intersection of bold vertical and horizontal box lines. +/// +/// If [glyph.ascii] is `false`, this is "╋". If it's `true`, this is +/// "+" instead. +String get crossBold => glyph.glyphs.crossBold; + +/// A bold horizontal box line with a vertical line going up from the middle. +/// +/// If [glyph.ascii] is `false`, this is "┻". If it's `true`, this is +/// "+" instead. +String get teeUpBold => glyph.glyphs.teeUpBold; + +/// A bold horizontal box line with a vertical line going down from the middle. +/// +/// If [glyph.ascii] is `false`, this is "┳". If it's `true`, this is +/// "+" instead. +String get teeDownBold => glyph.glyphs.teeDownBold; + +/// A bold vertical box line with a horizontal line going left from the middle. +/// +/// If [glyph.ascii] is `false`, this is "┫". If it's `true`, this is +/// "+" instead. +String get teeLeftBold => glyph.glyphs.teeLeftBold; + +/// A bold vertical box line with a horizontal line going right from the middle. +/// +/// If [glyph.ascii] is `false`, this is "┣". If it's `true`, this is +/// "+" instead. +String get teeRightBold => glyph.glyphs.teeRightBold; + +/// The top half of a bold vertical box line. +/// +/// If [glyph.ascii] is `false`, this is "╹". If it's `true`, this is +/// "'" instead. +String get upEndBold => glyph.glyphs.upEndBold; + +/// The bottom half of a bold vertical box line. +/// +/// If [glyph.ascii] is `false`, this is "╻". If it's `true`, this is +/// "," instead. +String get downEndBold => glyph.glyphs.downEndBold; + +/// The left half of a bold horizontal box line. +/// +/// If [glyph.ascii] is `false`, this is "╸". If it's `true`, this is +/// "-" instead. +String get leftEndBold => glyph.glyphs.leftEndBold; + +/// The right half of a bold horizontal box line. +/// +/// If [glyph.ascii] is `false`, this is "╺". If it's `true`, this is +/// "-" instead. +String get rightEndBold => glyph.glyphs.rightEndBold; + +/// A double horizontal line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "═". If it's `true`, this is +/// "=" instead. +String get horizontalLineDouble => glyph.glyphs.horizontalLineDouble; + +/// A double vertical line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "║". If it's `true`, this is +/// "|" instead. +String get verticalLineDouble => glyph.glyphs.verticalLineDouble; + +/// The double upper left-hand corner of a box. +/// +/// If [glyph.ascii] is `false`, this is "╔". If it's `true`, this is +/// "," instead. +String get topLeftCornerDouble => glyph.glyphs.topLeftCornerDouble; + +/// The double upper right-hand corner of a box. +/// +/// If [glyph.ascii] is `false`, this is "╗". If it's `true`, this is +/// "," instead. +String get topRightCornerDouble => glyph.glyphs.topRightCornerDouble; + +/// The double lower left-hand corner of a box. +/// +/// If [glyph.ascii] is `false`, this is "╚". If it's `true`, this is +/// """ instead. +String get bottomLeftCornerDouble => glyph.glyphs.bottomLeftCornerDouble; + +/// The double lower right-hand corner of a box. +/// +/// If [glyph.ascii] is `false`, this is "╝". If it's `true`, this is +/// """ instead. +String get bottomRightCornerDouble => glyph.glyphs.bottomRightCornerDouble; + +/// An intersection of double vertical and horizontal box lines. +/// +/// If [glyph.ascii] is `false`, this is "╬". If it's `true`, this is +/// "+" instead. +String get crossDouble => glyph.glyphs.crossDouble; + +/// A double horizontal box line with a vertical line going up from the middle. +/// +/// If [glyph.ascii] is `false`, this is "╩". If it's `true`, this is +/// "+" instead. +String get teeUpDouble => glyph.glyphs.teeUpDouble; + +/// A double horizontal box line with a vertical line going down from the +/// middle. +/// +/// If [glyph.ascii] is `false`, this is "╦". If it's `true`, this is +/// "+" instead. +String get teeDownDouble => glyph.glyphs.teeDownDouble; + +/// A double vertical box line with a horizontal line going left from the +/// middle. +/// +/// If [glyph.ascii] is `false`, this is "╣". If it's `true`, this is +/// "+" instead. +String get teeLeftDouble => glyph.glyphs.teeLeftDouble; + +/// A double vertical box line with a horizontal line going right from the +/// middle. +/// +/// If [glyph.ascii] is `false`, this is "╠". If it's `true`, this is +/// "+" instead. +String get teeRightDouble => glyph.glyphs.teeRightDouble; + +/// A dashed horizontal line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "╌". If it's `true`, this is +/// "-" instead. +String get horizontalLineDoubleDash => glyph.glyphs.horizontalLineDoubleDash; + +/// A bold dashed horizontal line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "╍". If it's `true`, this is +/// "-" instead. +String get horizontalLineDoubleDashBold => + glyph.glyphs.horizontalLineDoubleDashBold; + +/// A dashed vertical line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "╎". If it's `true`, this is +/// "|" instead. +String get verticalLineDoubleDash => glyph.glyphs.verticalLineDoubleDash; + +/// A bold dashed vertical line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "╏". If it's `true`, this is +/// "|" instead. +String get verticalLineDoubleDashBold => + glyph.glyphs.verticalLineDoubleDashBold; + +/// A dashed horizontal line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "┄". If it's `true`, this is +/// "-" instead. +String get horizontalLineTripleDash => glyph.glyphs.horizontalLineTripleDash; + +/// A bold dashed horizontal line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "┅". If it's `true`, this is +/// "-" instead. +String get horizontalLineTripleDashBold => + glyph.glyphs.horizontalLineTripleDashBold; + +/// A dashed vertical line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "┆". If it's `true`, this is +/// "|" instead. +String get verticalLineTripleDash => glyph.glyphs.verticalLineTripleDash; + +/// A bold dashed vertical line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "┇". If it's `true`, this is +/// "|" instead. +String get verticalLineTripleDashBold => + glyph.glyphs.verticalLineTripleDashBold; + +/// A dashed horizontal line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "┈". If it's `true`, this is +/// "-" instead. +String get horizontalLineQuadrupleDash => + glyph.glyphs.horizontalLineQuadrupleDash; + +/// A bold dashed horizontal line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "┉". If it's `true`, this is +/// "-" instead. +String get horizontalLineQuadrupleDashBold => + glyph.glyphs.horizontalLineQuadrupleDashBold; + +/// A dashed vertical line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "┊". If it's `true`, this is +/// "|" instead. +String get verticalLineQuadrupleDash => glyph.glyphs.verticalLineQuadrupleDash; + +/// A bold dashed vertical line that can be used to draw a box. +/// +/// If [glyph.ascii] is `false`, this is "┋". If it's `true`, this is +/// "|" instead. +String get verticalLineQuadrupleDashBold => + glyph.glyphs.verticalLineQuadrupleDashBold; diff --git a/pkgs/term_glyph/lib/src/generated/unicode_glyph_set.dart b/pkgs/term_glyph/lib/src/generated/unicode_glyph_set.dart new file mode 100644 index 000000000..1ddd165d7 --- /dev/null +++ b/pkgs/term_glyph/lib/src/generated/unicode_glyph_set.dart @@ -0,0 +1,137 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// Don't modify this file by hand! It's generated by tool/generate.dart. + +import 'glyph_set.dart'; + +/// A [GlyphSet] that includes only Unicode glyphs. +class UnicodeGlyphSet implements GlyphSet { + const UnicodeGlyphSet(); + + /// Returns [glyph] if `this` supports Unicode glyphs and [alternative] + /// otherwise. + @override + String glyphOrAscii(String glyph, String alternative) => glyph; + @override + String get bullet => '•'; + @override + String get leftArrow => '←'; + @override + String get rightArrow => '→'; + @override + String get upArrow => '↑'; + @override + String get downArrow => '↓'; + @override + String get longLeftArrow => '◀━'; + @override + String get longRightArrow => '━▶'; + @override + String get horizontalLine => '─'; + @override + String get verticalLine => '│'; + @override + String get topLeftCorner => '┌'; + @override + String get topRightCorner => '┐'; + @override + String get bottomLeftCorner => '└'; + @override + String get bottomRightCorner => '┘'; + @override + String get cross => '┼'; + @override + String get teeUp => '┴'; + @override + String get teeDown => '┬'; + @override + String get teeLeft => '┤'; + @override + String get teeRight => '├'; + @override + String get upEnd => '╵'; + @override + String get downEnd => '╷'; + @override + String get leftEnd => '╴'; + @override + String get rightEnd => '╶'; + @override + String get horizontalLineBold => '━'; + @override + String get verticalLineBold => '┃'; + @override + String get topLeftCornerBold => '┏'; + @override + String get topRightCornerBold => '┓'; + @override + String get bottomLeftCornerBold => '┗'; + @override + String get bottomRightCornerBold => '┛'; + @override + String get crossBold => '╋'; + @override + String get teeUpBold => '┻'; + @override + String get teeDownBold => '┳'; + @override + String get teeLeftBold => '┫'; + @override + String get teeRightBold => '┣'; + @override + String get upEndBold => '╹'; + @override + String get downEndBold => '╻'; + @override + String get leftEndBold => '╸'; + @override + String get rightEndBold => '╺'; + @override + String get horizontalLineDouble => '═'; + @override + String get verticalLineDouble => '║'; + @override + String get topLeftCornerDouble => '╔'; + @override + String get topRightCornerDouble => '╗'; + @override + String get bottomLeftCornerDouble => '╚'; + @override + String get bottomRightCornerDouble => '╝'; + @override + String get crossDouble => '╬'; + @override + String get teeUpDouble => '╩'; + @override + String get teeDownDouble => '╦'; + @override + String get teeLeftDouble => '╣'; + @override + String get teeRightDouble => '╠'; + @override + String get horizontalLineDoubleDash => '╌'; + @override + String get horizontalLineDoubleDashBold => '╍'; + @override + String get verticalLineDoubleDash => '╎'; + @override + String get verticalLineDoubleDashBold => '╏'; + @override + String get horizontalLineTripleDash => '┄'; + @override + String get horizontalLineTripleDashBold => '┅'; + @override + String get verticalLineTripleDash => '┆'; + @override + String get verticalLineTripleDashBold => '┇'; + @override + String get horizontalLineQuadrupleDash => '┈'; + @override + String get horizontalLineQuadrupleDashBold => '┉'; + @override + String get verticalLineQuadrupleDash => '┊'; + @override + String get verticalLineQuadrupleDashBold => '┋'; +} diff --git a/pkgs/term_glyph/lib/term_glyph.dart b/pkgs/term_glyph/lib/term_glyph.dart new file mode 100644 index 000000000..9f2b42293 --- /dev/null +++ b/pkgs/term_glyph/lib/term_glyph.dart @@ -0,0 +1,37 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'src/generated/ascii_glyph_set.dart'; +import 'src/generated/glyph_set.dart'; +import 'src/generated/unicode_glyph_set.dart'; + +export 'src/generated/glyph_set.dart'; +export 'src/generated/top_level.dart'; + +/// A [GlyphSet] that always returns ASCII glyphs. +const GlyphSet asciiGlyphs = AsciiGlyphSet(); + +/// A [GlyphSet] that always returns Unicode glyphs. +const GlyphSet unicodeGlyphs = UnicodeGlyphSet(); + +/// Returns [asciiGlyphs] if [ascii] is `true` or [unicodeGlyphs] otherwise. +/// +/// Returns [unicodeGlyphs] by default. +GlyphSet get glyphs => _glyphs; +GlyphSet _glyphs = unicodeGlyphs; + +/// Whether the glyph getters return plain ASCII, as opposed to Unicode +/// characters or sequences. +/// +/// Defaults to `false`. +bool get ascii => glyphs == asciiGlyphs; + +set ascii(bool value) { + _glyphs = value ? asciiGlyphs : unicodeGlyphs; +} + +/// Returns [glyph] if Unicode glyph are allowed, and [alternative] if they +/// aren't. +String glyphOrAscii(String glyph, String alternative) => + glyphs.glyphOrAscii(glyph, alternative); diff --git a/pkgs/term_glyph/pubspec.yaml b/pkgs/term_glyph/pubspec.yaml new file mode 100644 index 000000000..c429307b9 --- /dev/null +++ b/pkgs/term_glyph/pubspec.yaml @@ -0,0 +1,13 @@ +name: term_glyph +version: 1.2.2 +description: Useful Unicode glyphs and ASCII substitutes. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/term_glyph + +environment: + sdk: ^3.1.0 + +dev_dependencies: + csv: ^6.0.0 + dart_flutter_team_lints: ^3.0.0 + dart_style: ^2.0.0 + test: ^1.16.6 diff --git a/pkgs/term_glyph/test/symbol_test.dart b/pkgs/term_glyph/test/symbol_test.dart new file mode 100644 index 000000000..b3b4d0937 --- /dev/null +++ b/pkgs/term_glyph/test/symbol_test.dart @@ -0,0 +1,60 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:term_glyph/term_glyph.dart' as glyph; +import 'package:test/test.dart'; + +void main() { + group('with ascii = false', () { + setUpAll(() { + glyph.ascii = false; + }); + + test('glyph getters return Unicode versions', () { + expect(glyph.topLeftCorner, equals('┌')); + expect(glyph.teeUpBold, equals('┻')); + expect(glyph.longLeftArrow, equals('◀━')); + }); + + test('glyphOrAscii returns the first argument', () { + expect(glyph.glyphOrAscii('A', 'B'), equals('A')); + }); + + test('glyphs returns unicodeGlyphs', () { + expect(glyph.glyphs, equals(glyph.unicodeGlyphs)); + }); + + test('asciiGlyphs still returns ASCII characters', () { + expect(glyph.asciiGlyphs.topLeftCorner, equals(',')); + expect(glyph.asciiGlyphs.teeUpBold, equals('+')); + expect(glyph.asciiGlyphs.longLeftArrow, equals('<=')); + }); + }); + + group('with ascii = true', () { + setUpAll(() { + glyph.ascii = true; + }); + + test('glyphs return ASCII versions', () { + expect(glyph.topLeftCorner, equals(',')); + expect(glyph.teeUpBold, equals('+')); + expect(glyph.longLeftArrow, equals('<=')); + }); + + test('glyphOrAscii returns the second argument', () { + expect(glyph.glyphOrAscii('A', 'B'), equals('B')); + }); + + test('glyphs returns asciiGlyphs', () { + expect(glyph.glyphs, equals(glyph.asciiGlyphs)); + }); + + test('unicodeGlyphs still returns Unicode characters', () { + expect(glyph.unicodeGlyphs.topLeftCorner, equals('┌')); + expect(glyph.unicodeGlyphs.teeUpBold, equals('┻')); + expect(glyph.unicodeGlyphs.longLeftArrow, equals('◀━')); + }); + }); +} diff --git a/pkgs/term_glyph/tool/generate.dart b/pkgs/term_glyph/tool/generate.dart new file mode 100644 index 000000000..f5cdade9e --- /dev/null +++ b/pkgs/term_glyph/tool/generate.dart @@ -0,0 +1,153 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:io'; + +import 'package:csv/csv.dart'; + +void main() { + final csv = CsvCodec(eol: '\n'); + final data = csv.decoder.convert(File('data.csv').readAsStringSync()); + + // Remove comments and empty lines. + data.removeWhere((row) => row.length < 3); + + Directory('lib/src/generated').createSync(recursive: true); + + _writeGlyphSetInterface(data); + _writeGlyphSet(data, ascii: false); + _writeGlyphSet(data, ascii: true); + _writeTopLevel(data); + + final result = Process.runSync( + 'pub', ['run', 'dart_style:format', '-w', 'lib/src/generated']); + print(result.stderr); + exit(result.exitCode); +} + +/// Writes `lib/src/generated/glyph_set.dart`. +void _writeGlyphSetInterface(List> data) { + final file = + File('lib/src/generated/glyph_set.dart').openSync(mode: FileMode.write); + file.writeStringSync(r''' + // Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file + // for details. All rights reserved. Use of this source code is governed by a + // BSD-style license that can be found in the LICENSE file. + + // Don't modify this file by hand! It's generated by tool/generate.dart. + + /// A class that provides access to every configurable glyph. + /// + /// This is provided as a class so that individual chunks of code can choose + /// between `ascii` and `unicode` glyphs. For example: + /// + /// ```dart + /// import 'package:term_glyph/term_glyph.dart' as glyph; + /// + /// /// Adds a vertical line to the left of [text]. + /// /// + /// /// If [unicode] is `true`, this uses Unicode for the line. If it's + /// /// `false`, this uses plain ASCII characters. If it's `null`, it + /// /// defaults to [glyph.ascii]. + /// void addVerticalLine(String text, {bool unicode}) { + /// var glyphs = + /// (unicode ?? !glyph.ascii) ? glyph.unicodeGlyphs : glyph.asciiGlyphs; + /// + /// return text + /// .split('\n') + /// .map((line) => '${glyphs.verticalLine} $line') + /// .join('\n'); + /// } + /// ``` + abstract class GlyphSet { + /// Returns [glyph] if `this` supports Unicode glyphs and [alternative] + /// otherwise. + String glyphOrAscii(String glyph, String alternative); + '''); + + for (var glyph in data) { + for (var line in (glyph[3] as String).split('\n')) { + file.writeStringSync('/// $line\n'); + } + + file.writeStringSync('String get ${glyph[0]};'); + } + + file.writeStringSync('}'); + file.closeSync(); +} + +/// Writes `lib/src/generated/${prefix.toLowerCase()}_glyph_set.dart`. +/// +/// If [ascii] is `true`, this writes the ASCII glyph set. Otherwise it writes +/// the Unicode glyph set. +void _writeGlyphSet(List> data, {required bool ascii}) { + final file = + File('lib/src/generated/${ascii ? "ascii" : "unicode"}_glyph_set.dart') + .openSync(mode: FileMode.write); + + final className = '${ascii ? "Ascii" : "Unicode"}GlyphSet'; + file.writeStringSync(''' + // Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file + // for details. All rights reserved. Use of this source code is governed by a + // BSD-style license that can be found in the LICENSE file. + + // Don't modify this file by hand! It's generated by tool/generate.dart. + + import 'glyph_set.dart'; + + /// A [GlyphSet] that includes only ${ascii ? "ASCII" : "Unicode"} glyphs. + class $className implements GlyphSet { + const $className(); + /// Returns [glyph] if `this` supports Unicode glyphs and [alternative] + /// otherwise. + @override + String glyphOrAscii(String glyph, String alternative) => + ${ascii ? "alternative" : "glyph"}; + '''); + + final index = ascii ? 2 : 1; + for (var glyph in data) { + file.writeStringSync(''' + @override + String get ${glyph[0]} => ${_quote(glyph[index] as String)}; + '''); + } + + file.writeStringSync('}'); + file.closeSync(); +} + +/// Writes `lib/src/generated/top_level.dart`. +void _writeTopLevel(List> data) { + final file = + File('lib/src/generated/top_level.dart').openSync(mode: FileMode.write); + + file.writeStringSync(''' + // Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file + // for details. All rights reserved. Use of this source code is governed by a + // BSD-style license that can be found in the LICENSE file. + + // Don't modify this file by hand! It's generated by tool/generate.dart. + + import '../../term_glyph.dart' as glyph; + '''); + + for (var glyph in data) { + for (var line in (glyph[3] as String).split('\n')) { + file.writeStringSync('/// $line\n'); + } + + file.writeStringSync(''' + /// + /// If [glyph.ascii] is `false`, this is "${glyph[1]}". If it's `true`, this is + /// "${glyph[2]}" instead. + String get ${glyph[0]} => glyph.glyphs.${glyph[0]}; + '''); + } + + file.closeSync(); +} + +String _quote(String input) => input.contains("'") ? '"$input"' : "'$input'"; diff --git a/pkgs/test_reflective_loader/.gitignore b/pkgs/test_reflective_loader/.gitignore new file mode 100644 index 000000000..2a2c2612b --- /dev/null +++ b/pkgs/test_reflective_loader/.gitignore @@ -0,0 +1,11 @@ +.buildlog +.DS_Store +.idea +.dart_tool/ +.pub/ +.project +.settings/ +build/ +packages +.packages +pubspec.lock diff --git a/pkgs/test_reflective_loader/AUTHORS b/pkgs/test_reflective_loader/AUTHORS new file mode 100644 index 000000000..e8063a8cd --- /dev/null +++ b/pkgs/test_reflective_loader/AUTHORS @@ -0,0 +1,6 @@ +# Below is a list of people and organizations that have contributed +# to the project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. diff --git a/pkgs/test_reflective_loader/CHANGELOG.md b/pkgs/test_reflective_loader/CHANGELOG.md new file mode 100644 index 000000000..803eb0e0c --- /dev/null +++ b/pkgs/test_reflective_loader/CHANGELOG.md @@ -0,0 +1,72 @@ +## 0.2.3 + +- Require Dart `^3.1.0`. +- Move to `dart-lang/tools` monorepo. + +## 0.2.2 + +- Update to package:lints 2.0.0 and move it to a dev dependency. + +## 0.2.1 + +- Use package:lints for analysis. +- Populate the pubspec `repository` field. + +## 0.2.0 + +- Stable null safety release. + +## 0.2.0-nullsafety.0 + +- Migrate to the null safety language feature. + +## 0.1.9 + +- Add `@SkippedTest` annotation and `skip_test` prefix. + +## 0.1.8 + +- Update `FailingTest` to add named parameters `issue` and `reason`. + +## 0.1.7 + +- Update documentation comments. +- Remove `@MirrorsUsed` annotation on `dart:mirrors`. + +## 0.1.6 + +- Make `FailingTest` public, with the URI of the issue that causes + the test to break. + +## 0.1.5 + +- Set max SDK version to `<3.0.0`, and adjust other dependencies. + +## 0.1.3 + +- Fix `@failingTest` to fail when the test passes. + +## 0.1.2 + +- Update the pubspec `dependencies` section to include `package:test` + +## 0.1.1 + +- For `@failingTest` tests, properly handle when the test fails by throwing an + exception in a timer task +- Analyze this package in strong mode + +## 0.1.0 + +- Switched from 'package:unittest' to 'package:test'. +- Since 'package:test' does not define 'solo_test', in order to keep this + functionality, `defineReflectiveSuite` must be used to wrap all + `defineReflectiveTests` invocations. + +## 0.0.4 + +- Added @failingTest, @assertFailingTest and @soloTest annotations. + +## 0.0.1 + +- Initial version diff --git a/pkgs/test_reflective_loader/LICENSE b/pkgs/test_reflective_loader/LICENSE new file mode 100644 index 000000000..633672ab3 --- /dev/null +++ b/pkgs/test_reflective_loader/LICENSE @@ -0,0 +1,27 @@ +Copyright 2015, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/test_reflective_loader/README.md b/pkgs/test_reflective_loader/README.md new file mode 100644 index 000000000..9b5a83d4e --- /dev/null +++ b/pkgs/test_reflective_loader/README.md @@ -0,0 +1,28 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/test_reflective_loader.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/test_reflective_loader.yaml) +[![pub package](https://img.shields.io/pub/v/test_reflective_loader.svg)](https://pub.dev/packages/test_reflective_loader) +[![package publisher](https://img.shields.io/pub/publisher/test_reflective_loader.svg)](https://pub.dev/packages/test_reflective_loader/publisher) + +Support for discovering tests and test suites using reflection. + +This package follows the xUnit style where each class is a test suite, and each +method with the name prefix `test_` is a single test. + +Methods with names starting with `test_` are run using the `test()` function with +the corresponding name. If the class defines methods `setUp()` or `tearDown()`, +they are executed before / after each test correspondingly, even if the test fails. + +Methods with names starting with `solo_test_` are run using the `solo_test()` function. + +Methods with names starting with `fail_` are expected to fail. + +Methods with names starting with `solo_fail_` are run using the `solo_test()` function +and expected to fail. + +Method returning `Future` class instances are asynchronous, so `tearDown()` is +executed after the returned `Future` completes. + +## Features and bugs + +Please file feature requests and bugs at the [issue tracker][tracker]. + +[tracker]: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Atest_reflective_loader diff --git a/pkgs/test_reflective_loader/analysis_options.yaml b/pkgs/test_reflective_loader/analysis_options.yaml new file mode 100644 index 000000000..ea6115827 --- /dev/null +++ b/pkgs/test_reflective_loader/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:dart_flutter_team_lints/analysis_options.yaml + +linter: + rules: + - public_member_api_docs diff --git a/pkgs/test_reflective_loader/lib/test_reflective_loader.dart b/pkgs/test_reflective_loader/lib/test_reflective_loader.dart new file mode 100644 index 000000000..cb69bf3ba --- /dev/null +++ b/pkgs/test_reflective_loader/lib/test_reflective_loader.dart @@ -0,0 +1,354 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:mirrors'; + +import 'package:test/test.dart' as test_package; + +/// A marker annotation used to annotate test methods which are expected to fail +/// when asserts are enabled. +const Object assertFailingTest = _AssertFailingTest(); + +/// A marker annotation used to annotate test methods which are expected to +/// fail. +const Object failingTest = FailingTest(); + +/// A marker annotation used to instruct dart2js to keep reflection information +/// for the annotated classes. +const Object reflectiveTest = _ReflectiveTest(); + +/// A marker annotation used to annotate test methods that should be skipped. +const Object skippedTest = SkippedTest(); + +/// A marker annotation used to annotate "solo" groups and tests. +const Object soloTest = _SoloTest(); + +final List<_Group> _currentGroups = <_Group>[]; +int _currentSuiteLevel = 0; +String _currentSuiteName = ''; + +/// Is `true` the application is running in the checked mode. +final bool _isCheckedMode = () { + try { + assert(false); + return false; + } catch (_) { + return true; + } +}(); + +/// Run the [define] function parameter that calls [defineReflectiveTests] to +/// add normal and "solo" tests, and also calls [defineReflectiveSuite] to +/// create embedded suites. If the current suite is the top-level one, perform +/// check for "solo" groups and tests, and run all or only "solo" items. +void defineReflectiveSuite(void Function() define, {String name = ''}) { + var groupName = _currentSuiteName; + _currentSuiteLevel++; + try { + _currentSuiteName = _combineNames(_currentSuiteName, name); + define(); + } finally { + _currentSuiteName = groupName; + _currentSuiteLevel--; + } + _addTestsIfTopLevelSuite(); +} + +/// Runs test methods existing in the given [type]. +/// +/// If there is a "solo" test method in the top-level suite, only "solo" methods +/// are run. +/// +/// If there is a "solo" test type, only its test methods are run. +/// +/// Otherwise all tests methods of all test types are run. +/// +/// Each method is run with a new instance of [type]. +/// So, [type] should have a default constructor. +/// +/// If [type] declares method `setUp`, it methods will be invoked before any +/// test method invocation. +/// +/// If [type] declares method `tearDown`, it will be invoked after any test +/// method invocation. If method returns [Future] to test some asynchronous +/// behavior, then `tearDown` will be invoked in `Future.complete`. +void defineReflectiveTests(Type type) { + var classMirror = reflectClass(type); + if (!classMirror.metadata.any((InstanceMirror annotation) => + annotation.type.reflectedType == _ReflectiveTest)) { + var name = MirrorSystem.getName(classMirror.qualifiedName); + throw Exception('Class $name must have annotation "@reflectiveTest" ' + 'in order to be run by runReflectiveTests.'); + } + + _Group group; + { + var isSolo = _hasAnnotationInstance(classMirror, soloTest); + var className = MirrorSystem.getName(classMirror.simpleName); + group = _Group(isSolo, _combineNames(_currentSuiteName, className)); + _currentGroups.add(group); + } + + classMirror.instanceMembers + .forEach((Symbol symbol, MethodMirror memberMirror) { + // we need only methods + if (!memberMirror.isRegularMethod) { + return; + } + // prepare information about the method + var memberName = MirrorSystem.getName(symbol); + var isSolo = memberName.startsWith('solo_') || + _hasAnnotationInstance(memberMirror, soloTest); + // test_ + if (memberName.startsWith('test_')) { + if (_hasSkippedTestAnnotation(memberMirror)) { + group.addSkippedTest(memberName); + } else { + group.addTest(isSolo, memberName, memberMirror, () { + if (_hasFailingTestAnnotation(memberMirror) || + _isCheckedMode && _hasAssertFailingTestAnnotation(memberMirror)) { + return _runFailingTest(classMirror, symbol); + } else { + return _runTest(classMirror, symbol); + } + }); + } + return; + } + // solo_test_ + if (memberName.startsWith('solo_test_')) { + group.addTest(true, memberName, memberMirror, () { + return _runTest(classMirror, symbol); + }); + } + // fail_test_ + if (memberName.startsWith('fail_')) { + group.addTest(isSolo, memberName, memberMirror, () { + return _runFailingTest(classMirror, symbol); + }); + } + // solo_fail_test_ + if (memberName.startsWith('solo_fail_')) { + group.addTest(true, memberName, memberMirror, () { + return _runFailingTest(classMirror, symbol); + }); + } + // skip_test_ + if (memberName.startsWith('skip_test_')) { + group.addSkippedTest(memberName); + } + }); + + // Support for the case of missing enclosing [defineReflectiveSuite]. + _addTestsIfTopLevelSuite(); +} + +/// If the current suite is the top-level one, add tests to the `test` package. +void _addTestsIfTopLevelSuite() { + if (_currentSuiteLevel == 0) { + void runTests({required bool allGroups, required bool allTests}) { + for (var group in _currentGroups) { + if (allGroups || group.isSolo) { + for (var test in group.tests) { + if (allTests || test.isSolo) { + test_package.test(test.name, test.function, + timeout: test.timeout, skip: test.isSkipped); + } + } + } + } + } + + if (_currentGroups.any((g) => g.hasSoloTest)) { + runTests(allGroups: true, allTests: false); + } else if (_currentGroups.any((g) => g.isSolo)) { + runTests(allGroups: false, allTests: true); + } else { + runTests(allGroups: true, allTests: true); + } + _currentGroups.clear(); + } +} + +/// Return the combination of the [base] and [addition] names. +/// If any other two is `null`, then the other one is returned. +String _combineNames(String base, String addition) { + if (base.isEmpty) { + return addition; + } else if (addition.isEmpty) { + return base; + } else { + return '$base | $addition'; + } +} + +Object? _getAnnotationInstance(DeclarationMirror declaration, Type type) { + for (var annotation in declaration.metadata) { + if ((annotation.reflectee as Object).runtimeType == type) { + return annotation.reflectee; + } + } + return null; +} + +bool _hasAnnotationInstance(DeclarationMirror declaration, Object instance) => + declaration.metadata.any((InstanceMirror annotation) => + identical(annotation.reflectee, instance)); + +bool _hasAssertFailingTestAnnotation(MethodMirror method) => + _hasAnnotationInstance(method, assertFailingTest); + +bool _hasFailingTestAnnotation(MethodMirror method) => + _hasAnnotationInstance(method, failingTest); + +bool _hasSkippedTestAnnotation(MethodMirror method) => + _hasAnnotationInstance(method, skippedTest); + +Future _invokeSymbolIfExists( + InstanceMirror instanceMirror, Symbol symbol) { + Object? invocationResult; + InstanceMirror? closure; + try { + closure = instanceMirror.getField(symbol); + // ignore: avoid_catching_errors + } on NoSuchMethodError { + // ignore + } + + if (closure is ClosureMirror) { + invocationResult = closure.apply([]).reflectee; + } + return Future.value(invocationResult); +} + +/// Run a test that is expected to fail, and confirm that it fails. +/// +/// This properly handles the following cases: +/// - The test fails by throwing an exception +/// - The test returns a future which completes with an error. +/// - An exception is thrown to the zone handler from a timer task. +Future? _runFailingTest(ClassMirror classMirror, Symbol symbol) { + var passed = false; + return runZonedGuarded(() { + // ignore: void_checks + return Future.sync(() => _runTest(classMirror, symbol)).then((_) { + passed = true; + test_package.fail('Test passed - expected to fail.'); + }).catchError((Object e) { + // if passed, and we call fail(), rethrow this exception + if (passed) { + // ignore: only_throw_errors + throw e; + } + // otherwise, an exception is not a failure for _runFailingTest + }); + }, (e, st) { + // if passed, and we call fail(), rethrow this exception + if (passed) { + // ignore: only_throw_errors + throw e; + } + // otherwise, an exception is not a failure for _runFailingTest + }); +} + +Future _runTest(ClassMirror classMirror, Symbol symbol) async { + var instanceMirror = classMirror.newInstance(const Symbol(''), []); + try { + await _invokeSymbolIfExists(instanceMirror, #setUp); + await instanceMirror.invoke(symbol, []).reflectee; + } finally { + await _invokeSymbolIfExists(instanceMirror, #tearDown); + } +} + +typedef _TestFunction = dynamic Function(); + +/// A marker annotation used to annotate test methods which are expected to +/// fail. +class FailingTest { + /// Initialize this annotation with the given arguments. + /// + /// [issue] is a full URI describing the failure and used for tracking. + /// [reason] is a free form textual description. + const FailingTest({String? issue, String? reason}); +} + +/// A marker annotation used to annotate test methods which are skipped. +class SkippedTest { + /// Initialize this annotation with the given arguments. + /// + /// [issue] is a full URI describing the failure and used for tracking. + /// [reason] is a free form textual description. + const SkippedTest({String? issue, String? reason}); +} + +/// A marker annotation used to annotate test methods with additional timeout +/// information. +class TestTimeout { + final test_package.Timeout _timeout; + + /// Initialize this annotation with the given timeout. + const TestTimeout(test_package.Timeout timeout) : _timeout = timeout; +} + +/// A marker annotation used to annotate test methods which are expected to fail +/// when asserts are enabled. +class _AssertFailingTest { + const _AssertFailingTest(); +} + +/// Information about a type based test group. +class _Group { + final bool isSolo; + final String name; + final List<_Test> tests = <_Test>[]; + + _Group(this.isSolo, this.name); + + bool get hasSoloTest => tests.any((test) => test.isSolo); + + void addSkippedTest(String name) { + var fullName = _combineNames(this.name, name); + tests.add(_Test.skipped(isSolo, fullName)); + } + + void addTest(bool isSolo, String name, MethodMirror memberMirror, + _TestFunction function) { + var fullName = _combineNames(this.name, name); + var timeout = + _getAnnotationInstance(memberMirror, TestTimeout) as TestTimeout?; + tests.add(_Test(isSolo, fullName, function, timeout?._timeout)); + } +} + +/// A marker annotation used to instruct dart2js to keep reflection information +/// for the annotated classes. +class _ReflectiveTest { + const _ReflectiveTest(); +} + +/// A marker annotation used to annotate "solo" groups and tests. +class _SoloTest { + const _SoloTest(); +} + +/// Information about a test. +class _Test { + final bool isSolo; + final String name; + final _TestFunction function; + final test_package.Timeout? timeout; + + final bool isSkipped; + + _Test(this.isSolo, this.name, this.function, this.timeout) + : isSkipped = false; + + _Test.skipped(this.isSolo, this.name) + : isSkipped = true, + function = (() {}), + timeout = null; +} diff --git a/pkgs/test_reflective_loader/pubspec.yaml b/pkgs/test_reflective_loader/pubspec.yaml new file mode 100644 index 000000000..569933f23 --- /dev/null +++ b/pkgs/test_reflective_loader/pubspec.yaml @@ -0,0 +1,13 @@ +name: test_reflective_loader +version: 0.2.3 +description: Support for discovering tests and test suites using reflection. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/test_reflective_loader + +environment: + sdk: ^3.1.0 + +dependencies: + test: ^1.16.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 diff --git a/pkgs/test_reflective_loader/test/test_reflective_loader_test.dart b/pkgs/test_reflective_loader/test/test_reflective_loader_test.dart new file mode 100644 index 000000000..fad98a5a1 --- /dev/null +++ b/pkgs/test_reflective_loader/test/test_reflective_loader_test.dart @@ -0,0 +1,48 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// ignore_for_file: non_constant_identifier_names + +import 'dart:async'; + +import 'package:test/test.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(TestReflectiveLoaderTest); + }); +} + +@reflectiveTest +class TestReflectiveLoaderTest { + void test_passes() { + expect(true, true); + } + + @failingTest + void test_fails() { + expect(false, true); + } + + @failingTest + void test_fails_throws_sync() { + throw StateError('foo'); + } + + @failingTest + Future test_fails_throws_async() { + return Future.error('foo'); + } + + @skippedTest + void test_fails_but_skipped() { + throw StateError('foo'); + } + + @skippedTest + void test_times_out_but_skipped() { + while (true) {} + } +} diff --git a/pkgs/timing/.gitignore b/pkgs/timing/.gitignore new file mode 100644 index 000000000..1ddf798b7 --- /dev/null +++ b/pkgs/timing/.gitignore @@ -0,0 +1,7 @@ +.packages +/build/ +pubspec.lock + +# Files generated by dart tools +.dart_tool +doc/ diff --git a/pkgs/timing/CHANGELOG.md b/pkgs/timing/CHANGELOG.md new file mode 100644 index 000000000..8cdb8eadc --- /dev/null +++ b/pkgs/timing/CHANGELOG.md @@ -0,0 +1,34 @@ +## 1.0.2 + +- Require Dart `3.4`. +- Move to `dart-lang/tools` monorepo. + +## 1.0.1 + +- Require Dart `2.14`. + +## 1.0.0 + +- Enable null safety. +- Require Dart `2.12`. + +## 0.1.1+3 + +- Allow `package:json_annotation` `'>=1.0.0 <5.0.0'`. + +## 0.1.1+2 + +- Support the latest version of `package:json_annotation`. +- Require Dart 2.2 or later. + +## 0.1.1+1 + +- Support the latest version of `package:json_annotation`. + +## 0.1.1 + +- Add JSON serialization + +## 0.1.0 + +- Initial release diff --git a/pkgs/timing/LICENSE b/pkgs/timing/LICENSE new file mode 100644 index 000000000..9972f6e70 --- /dev/null +++ b/pkgs/timing/LICENSE @@ -0,0 +1,27 @@ +Copyright 2018, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/timing/README.md b/pkgs/timing/README.md new file mode 100644 index 000000000..9dab7cc51 --- /dev/null +++ b/pkgs/timing/README.md @@ -0,0 +1,30 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/timing.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/timing.yaml) +[![pub package](https://img.shields.io/pub/v/timing.svg)](https://pub.dev/packages/timing) +[![package publisher](https://img.shields.io/pub/publisher/timing.svg)](https://pub.dev/packages/timing/publisher) + +Timing is a simple package for tracking performance of both async and sync actions + +## Usage + +```dart +var tracker = AsyncTimeTracker(); +await tracker.track(() async { + // some async code here +}); + +// Use results +print('${tracker.duration} ${tracker.innerDuration} ${tracker.slices}'); +``` + +## Building + +Use the following command to re-generate `lib/src/timing.g.dart` file: + +```bash +dart pub run build_runner build +``` + +## Publishing automation + +For information about our publishing automation and release process, see +https://github.com/dart-lang/ecosystem/wiki/Publishing-automation. diff --git a/pkgs/timing/analysis_options.yaml b/pkgs/timing/analysis_options.yaml new file mode 100644 index 000000000..396236d68 --- /dev/null +++ b/pkgs/timing/analysis_options.yaml @@ -0,0 +1,2 @@ +# https://dart.dev/tools/analysis#the-analysis-options-file +include: package:dart_flutter_team_lints/analysis_options.yaml diff --git a/pkgs/timing/lib/src/clock.dart b/pkgs/timing/lib/src/clock.dart new file mode 100644 index 000000000..6a9d29509 --- /dev/null +++ b/pkgs/timing/lib/src/clock.dart @@ -0,0 +1,20 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +/// A function that returns the current [DateTime]. +typedef _Clock = DateTime Function(); +DateTime _defaultClock() => DateTime.now(); + +const _zoneKey = #timing_Clock; + +/// Returns the current [DateTime]. +/// +/// May be overridden for tests using [scopeClock]. +DateTime now() => (Zone.current[_zoneKey] as _Clock? ?? _defaultClock)(); + +/// Runs [f], with [clock] scoped whenever [now] is called. +T scopeClock(DateTime Function() clock, T Function() f) => + runZoned(f, zoneValues: {_zoneKey: clock}); diff --git a/pkgs/timing/lib/src/timing.dart b/pkgs/timing/lib/src/timing.dart new file mode 100644 index 000000000..049ba8189 --- /dev/null +++ b/pkgs/timing/lib/src/timing.dart @@ -0,0 +1,338 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:json_annotation/json_annotation.dart'; + +import 'clock.dart'; + +part 'timing.g.dart'; + +/// The timings of an operation, including its [startTime], [stopTime], and +/// [duration]. +@JsonSerializable() +class TimeSlice { + /// The total duration of this operation, equivalent to taking the difference + /// between [stopTime] and [startTime]. + Duration get duration => stopTime.difference(startTime); + + final DateTime startTime; + + final DateTime stopTime; + + TimeSlice(this.startTime, this.stopTime); + + factory TimeSlice.fromJson(Map json) => + _$TimeSliceFromJson(json); + + Map toJson() => _$TimeSliceToJson(this); + + @override + String toString() => '($startTime + $duration)'; +} + +/// The timings of an async operation, consist of several sync [slices] and +/// includes total [startTime], [stopTime], and [duration]. +@JsonSerializable() +class TimeSliceGroup implements TimeSlice { + final List slices; + + @override + DateTime get startTime => slices.first.startTime; + + @override + DateTime get stopTime => slices.last.stopTime; + + /// The total duration of this operation, equivalent to taking the difference + /// between [stopTime] and [startTime]. + @override + Duration get duration => stopTime.difference(startTime); + + /// Sum of [duration]s of all [slices]. + /// + /// If some of slices implements [TimeSliceGroup] [innerDuration] will be used + /// to compute sum. + Duration get innerDuration => slices.fold( + Duration.zero, + (duration, slice) => + duration + + (slice is TimeSliceGroup ? slice.innerDuration : slice.duration)); + + TimeSliceGroup(this.slices); + + /// Constructs TimeSliceGroup from JSON representation + factory TimeSliceGroup.fromJson(Map json) => + _$TimeSliceGroupFromJson(json); + + @override + Map toJson() => _$TimeSliceGroupToJson(this); + + @override + String toString() => slices.toString(); +} + +abstract class TimeTracker implements TimeSlice { + /// Whether tracking is active. + /// + /// Tracking is only active after `isStarted` and before `isFinished`. + bool get isTracking; + + /// Whether tracking is finished. + /// + /// Tracker can't be used as [TimeSlice] before it is finished + bool get isFinished; + + /// Whether tracking was started. + /// + /// Equivalent of `isTracking || isFinished` + bool get isStarted; + + T track(T Function() action); +} + +/// Tracks only sync actions +class SyncTimeTracker implements TimeTracker { + /// When this operation started, call [_start] to set this. + @override + DateTime get startTime => _startTime!; + DateTime? _startTime; + + /// When this operation stopped, call [_stop] to set this. + @override + DateTime get stopTime => _stopTime!; + DateTime? _stopTime; + + /// Start tracking this operation, must only be called once, before [_stop]. + void _start() { + assert(_startTime == null && _stopTime == null); + _startTime = now(); + } + + /// Stop tracking this operation, must only be called once, after [_start]. + void _stop() { + assert(_startTime != null && _stopTime == null); + _stopTime = now(); + } + + /// Splits tracker into two slices. + /// + /// Returns new [TimeSlice] started on [startTime] and ended now. Modifies + /// [startTime] of tracker to current time point + /// + /// Don't change state of tracker. Can be called only while [isTracking], and + /// tracker will sill be tracking after call. + TimeSlice _split() { + if (!isTracking) { + throw StateError('Can be only called while tracking'); + } + final splitPoint = now(); + final prevSlice = TimeSlice(_startTime!, splitPoint); + _startTime = splitPoint; + return prevSlice; + } + + @override + T track(T Function() action) { + if (isStarted) { + throw StateError('Can not be tracked twice'); + } + _start(); + try { + return action(); + } finally { + _stop(); + } + } + + @override + bool get isStarted => _startTime != null; + + @override + bool get isTracking => _startTime != null && _stopTime == null; + + @override + bool get isFinished => _startTime != null && _stopTime != null; + + @override + Duration get duration => _stopTime!.difference(_startTime!); + + /// Converts to JSON representation + /// + /// Can't be used before [isFinished] + @override + Map toJson() => _$TimeSliceToJson(this); +} + +/// Async actions returning [Future] will be tracked as single sync time span +/// from the beginning of execution till completion of future +class SimpleAsyncTimeTracker extends SyncTimeTracker { + @override + T track(T Function() action) { + if (isStarted) { + throw StateError('Can not be tracked twice'); + } + T result; + _start(); + try { + result = action(); + } catch (_) { + _stop(); + rethrow; + } + if (result is Future) { + return result.whenComplete(_stop) as T; + } else { + _stop(); + return result; + } + } +} + +/// No-op implementation of [SyncTimeTracker] that does nothing. +class NoOpTimeTracker implements TimeTracker { + static final sharedInstance = NoOpTimeTracker(); + + @override + Duration get duration => + throw UnsupportedError('Unsupported in no-op implementation'); + + @override + DateTime get startTime => + throw UnsupportedError('Unsupported in no-op implementation'); + + @override + DateTime get stopTime => + throw UnsupportedError('Unsupported in no-op implementation'); + + @override + bool get isStarted => + throw UnsupportedError('Unsupported in no-op implementation'); + + @override + bool get isTracking => + throw UnsupportedError('Unsupported in no-op implementation'); + + @override + bool get isFinished => + throw UnsupportedError('Unsupported in no-op implementation'); + + @override + T track(T Function() action) => action(); + + @override + Map toJson() => + throw UnsupportedError('Unsupported in no-op implementation'); +} + +/// Track all async execution as disjoint time [slices] in ascending order. +/// +/// Can [track] both async and sync actions. +/// Can exclude time of tested trackers. +/// +/// If tracked action spawns some dangled async executions behavior is't +/// defined. Tracked might or might not track time of such executions +class AsyncTimeTracker extends TimeSliceGroup implements TimeTracker { + final bool trackNested; + + static const _zoneKey = #timing_AsyncTimeTracker; + + AsyncTimeTracker({this.trackNested = true}) : super([]); + + T _trackSyncSlice(ZoneDelegate parent, Zone zone, T Function() action) { + // Ignore dangling runs after tracker completes + if (isFinished) { + return action(); + } + + final isNestedRun = slices.isNotEmpty && + slices.last is SyncTimeTracker && + (slices.last as SyncTimeTracker).isTracking; + final isExcludedNestedTrack = !trackNested && zone[_zoneKey] != this; + + // Exclude nested sync tracks + if (isNestedRun && isExcludedNestedTrack) { + final timer = slices.last as SyncTimeTracker; + // Split already tracked time into new slice. + // Replace tracker in slices.last with splitted slice, to indicate for + // recursive calls that we not tracking. + slices.last = parent.run(zone, timer._split); + try { + return action(); + } finally { + // Split tracker again and discard slice from nested tracker + parent.run(zone, timer._split); + // Add tracker back to list of slices and continue tracking + slices.add(timer); + } + } + + // Exclude nested async tracks + if (isExcludedNestedTrack) { + return action(); + } + + // Split time slices in nested sync runs + if (isNestedRun) { + return action(); + } + + final timer = SyncTimeTracker(); + slices.add(timer); + + // Pass to parent zone, in case of overwritten clock + return parent.runUnary(zone, timer.track, action); + } + + static final asyncTimeTrackerZoneSpecification = ZoneSpecification( + run: (Zone self, ZoneDelegate parent, Zone zone, R Function() f) { + final tracker = self[_zoneKey] as AsyncTimeTracker; + return tracker._trackSyncSlice(parent, zone, () => parent.run(zone, f)); + }, + runUnary: (Zone self, ZoneDelegate parent, Zone zone, R Function(T) f, + T arg) { + final tracker = self[_zoneKey] as AsyncTimeTracker; + return tracker._trackSyncSlice( + parent, zone, () => parent.runUnary(zone, f, arg)); + }, + runBinary: (Zone self, ZoneDelegate parent, Zone zone, + R Function(T1, T2) f, T1 arg1, T2 arg2) { + final tracker = self[_zoneKey] as AsyncTimeTracker; + return tracker._trackSyncSlice( + parent, zone, () => parent.runBinary(zone, f, arg1, arg2)); + }, + ); + + @override + T track(T Function() action) { + if (isStarted) { + throw StateError('Can not be tracked twice'); + } + _tracking = true; + final result = runZoned(action, + zoneSpecification: asyncTimeTrackerZoneSpecification, + zoneValues: {_zoneKey: this}); + if (result is Future) { + return result + // Break possible sync processing of future completion, so slice + // trackers can be finished + .whenComplete(Future.value) + .whenComplete(() => _tracking = false) as T; + } else { + _tracking = false; + return result; + } + } + + bool? _tracking; + + @override + bool get isStarted => _tracking != null; + + @override + bool get isFinished => _tracking == false; + + @override + bool get isTracking => _tracking == true; +} diff --git a/pkgs/timing/lib/src/timing.g.dart b/pkgs/timing/lib/src/timing.g.dart new file mode 100644 index 000000000..679c082ff --- /dev/null +++ b/pkgs/timing/lib/src/timing.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timing.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TimeSlice _$TimeSliceFromJson(Map json) => TimeSlice( + DateTime.parse(json['startTime'] as String), + DateTime.parse(json['stopTime'] as String), + ); + +Map _$TimeSliceToJson(TimeSlice instance) => { + 'startTime': instance.startTime.toIso8601String(), + 'stopTime': instance.stopTime.toIso8601String(), + }; + +TimeSliceGroup _$TimeSliceGroupFromJson(Map json) => + TimeSliceGroup( + (json['slices'] as List) + .map((e) => TimeSlice.fromJson(e as Map)) + .toList(), + ); + +Map _$TimeSliceGroupToJson(TimeSliceGroup instance) => + { + 'slices': instance.slices, + }; diff --git a/pkgs/timing/lib/timing.dart b/pkgs/timing/lib/timing.dart new file mode 100644 index 000000000..5cb16d423 --- /dev/null +++ b/pkgs/timing/lib/timing.dart @@ -0,0 +1,13 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'src/timing.dart' + show + AsyncTimeTracker, + NoOpTimeTracker, + SimpleAsyncTimeTracker, + SyncTimeTracker, + TimeSlice, + TimeSliceGroup, + TimeTracker; diff --git a/pkgs/timing/pubspec.yaml b/pkgs/timing/pubspec.yaml new file mode 100644 index 000000000..891a8af3d --- /dev/null +++ b/pkgs/timing/pubspec.yaml @@ -0,0 +1,18 @@ +name: timing +version: 1.0.2 +description: >- + A simple package for tracking the performance of synchronous and asynchronous + actions. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/timing + +environment: + sdk: ^3.4.0 + +dependencies: + json_annotation: ^4.9.0 + +dev_dependencies: + build_runner: ^2.0.6 + dart_flutter_team_lints: ^3.0.0 + json_serializable: ^6.0.0 + test: ^1.17.10 diff --git a/pkgs/timing/test/timing_test.dart b/pkgs/timing/test/timing_test.dart new file mode 100644 index 000000000..b5836d9d2 --- /dev/null +++ b/pkgs/timing/test/timing_test.dart @@ -0,0 +1,416 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// ignore_for_file: only_throw_errors, inference_failure_on_instance_creation + +import 'dart:async'; + +import 'package:test/test.dart'; +import 'package:timing/src/clock.dart'; +import 'package:timing/src/timing.dart'; + +void _noop() {} + +void main() { + late DateTime time; + final startTime = DateTime(2017); + DateTime fakeClock() => time; + + late TimeTracker tracker; + late TimeTracker nestedTracker; + + T scopedTrack(T Function() f) => + scopeClock(fakeClock, () => tracker.track(f)); + + setUp(() { + time = startTime; + }); + + void canHandleSync([void Function() additionalExpects = _noop]) { + test('Can track sync code', () { + expect(tracker.isStarted, false); + expect(tracker.isTracking, false); + expect(tracker.isFinished, false); + scopedTrack(() { + expect(tracker.isStarted, true); + expect(tracker.isTracking, true); + expect(tracker.isFinished, false); + time = time.add(const Duration(seconds: 5)); + }); + expect(tracker.isStarted, true); + expect(tracker.isTracking, false); + expect(tracker.isFinished, true); + expect(tracker.startTime, startTime); + expect(tracker.stopTime, time); + expect(tracker.duration, const Duration(seconds: 5)); + additionalExpects(); + }); + + test('Can track handled sync exceptions', () async { + scopedTrack(() { + try { + time = time.add(const Duration(seconds: 4)); + throw 'error'; + } on String { + time = time.add(const Duration(seconds: 1)); + } + }); + expect(tracker.isFinished, true); + expect(tracker.startTime, startTime); + expect(tracker.stopTime, time); + expect(tracker.duration, const Duration(seconds: 5)); + additionalExpects(); + }); + + test('Can track in case of unhandled sync exceptions', () async { + expect( + () => scopedTrack(() { + time = time.add(const Duration(seconds: 5)); + throw 'error'; + }), + throwsA(const TypeMatcher())); + expect(tracker.startTime, startTime); + expect(tracker.stopTime, time); + expect(tracker.duration, const Duration(seconds: 5)); + additionalExpects(); + }); + + test('Can be nested sync', () { + scopedTrack(() { + time = time.add(const Duration(seconds: 1)); + nestedTracker.track(() { + time = time.add(const Duration(seconds: 2)); + }); + time = time.add(const Duration(seconds: 4)); + }); + expect(tracker.isFinished, true); + expect(tracker.startTime, startTime); + expect(tracker.stopTime, time); + expect(tracker.duration, const Duration(seconds: 7)); + expect(nestedTracker.startTime.isAfter(startTime), true); + expect(nestedTracker.stopTime.isBefore(time), true); + expect(nestedTracker.duration, const Duration(seconds: 2)); + additionalExpects(); + }); + } + + void canHandleAsync([void Function() additionalExpects = _noop]) { + test('Can track async code', () async { + expect(tracker.isStarted, false); + expect(tracker.isTracking, false); + expect(tracker.isFinished, false); + await scopedTrack(() => Future(() { + expect(tracker.isStarted, true); + expect(tracker.isTracking, true); + expect(tracker.isFinished, false); + time = time.add(const Duration(seconds: 5)); + })); + expect(tracker.isStarted, true); + expect(tracker.isTracking, false); + expect(tracker.isFinished, true); + expect(tracker.startTime, startTime); + expect(tracker.stopTime, time); + expect(tracker.duration, const Duration(seconds: 5)); + additionalExpects(); + }); + + test('Can track handled async exceptions', () async { + await scopedTrack(() { + time = time.add(const Duration(seconds: 1)); + return Future(() { + time = time.add(const Duration(seconds: 2)); + throw 'error'; + }).then((_) { + time = time.add(const Duration(seconds: 4)); + }).catchError((error, stack) { + time = time.add(const Duration(seconds: 8)); + }); + }); + expect(tracker.isFinished, true); + expect(tracker.startTime, startTime); + expect(tracker.stopTime, time); + expect(tracker.duration, const Duration(seconds: 11)); + additionalExpects(); + }); + + test('Can track in case of unhandled async exceptions', () async { + final future = scopedTrack(() { + time = time.add(const Duration(seconds: 1)); + return Future(() { + time = time.add(const Duration(seconds: 2)); + throw 'error'; + }).then((_) { + time = time.add(const Duration(seconds: 4)); + }); + }); + await expectLater(future, throwsA(const TypeMatcher())); + expect(tracker.isFinished, true); + expect(tracker.startTime, startTime); + expect(tracker.stopTime, time); + expect(tracker.duration, const Duration(seconds: 3)); + additionalExpects(); + }); + + test('Can be nested async', () async { + await scopedTrack(() async { + time = time.add(const Duration(milliseconds: 1)); + await Future.value(); + time = time.add(const Duration(milliseconds: 2)); + await nestedTracker.track(() async { + time = time.add(const Duration(milliseconds: 4)); + await Future.value(); + time = time.add(const Duration(milliseconds: 8)); + await Future.value(); + time = time.add(const Duration(milliseconds: 16)); + }); + time = time.add(const Duration(milliseconds: 32)); + await Future.value(); + time = time.add(const Duration(milliseconds: 64)); + }); + expect(tracker.isFinished, true); + expect(tracker.startTime, startTime); + expect(tracker.stopTime, time); + expect(tracker.duration, const Duration(milliseconds: 127)); + expect(nestedTracker.startTime.isAfter(startTime), true); + expect(nestedTracker.stopTime.isBefore(time), true); + expect(nestedTracker.duration, const Duration(milliseconds: 28)); + additionalExpects(); + }); + } + + group('SyncTimeTracker', () { + setUp(() { + tracker = SyncTimeTracker(); + nestedTracker = SyncTimeTracker(); + }); + + canHandleSync(); + + test('Can not track async code', () async { + await scopedTrack(() => Future(() { + time = time.add(const Duration(seconds: 5)); + })); + expect(tracker.isFinished, true); + expect(tracker.startTime, startTime); + expect(tracker.stopTime, startTime); + expect(tracker.duration, const Duration(seconds: 0)); + }); + }); + + group('AsyncTimeTracker.simple', () { + setUp(() { + tracker = SimpleAsyncTimeTracker(); + nestedTracker = SimpleAsyncTimeTracker(); + }); + + canHandleSync(); + + canHandleAsync(); + + test('Can not distinguish own async code', () async { + final future = scopedTrack(() => Future(() { + time = time.add(const Duration(seconds: 5)); + })); + time = time.add(const Duration(seconds: 10)); + await future; + expect(tracker.isFinished, true); + expect(tracker.startTime, startTime); + expect(tracker.stopTime, time); + expect(tracker.duration, const Duration(seconds: 15)); + }); + }); + + group('AsyncTimeTracker', () { + late AsyncTimeTracker asyncTracker; + late AsyncTimeTracker nestedAsyncTracker; + setUp(() { + tracker = asyncTracker = AsyncTimeTracker(); + nestedTracker = nestedAsyncTracker = AsyncTimeTracker(); + }); + + canHandleSync(() { + expect(asyncTracker.innerDuration, asyncTracker.duration); + expect(asyncTracker.slices.length, 1); + }); + + canHandleAsync(() { + expect(asyncTracker.innerDuration, asyncTracker.duration); + expect(asyncTracker.slices.length, greaterThan(1)); + }); + + test('Can track complex async innerDuration', () async { + final completer = Completer(); + final future = scopedTrack(() async { + time = time.add(const Duration(seconds: 1)); // Tracked sync + await Future.value(); + time = time.add(const Duration(seconds: 2)); // Tracked async + await completer.future; + time = time.add(const Duration(seconds: 4)); // Tracked async, delayed + }).then((_) { + time = time.add(const Duration(seconds: 8)); // Async, after tracking + }); + time = time.add(const Duration(seconds: 16)); // Sync, between slices + + await Future(() { + // Async, between slices + time = time.add(const Duration(seconds: 32)); + completer.complete(); + }); + await future; + expect(asyncTracker.isFinished, true); + expect(asyncTracker.startTime, startTime); + expect(asyncTracker.stopTime.isBefore(time), true); + expect(asyncTracker.duration, const Duration(seconds: 55)); + expect(asyncTracker.innerDuration, const Duration(seconds: 7)); + expect(asyncTracker.slices.length, greaterThan(1)); + }); + + test('Can exclude nested sync', () { + tracker = asyncTracker = AsyncTimeTracker(trackNested: false); + scopedTrack(() { + time = time.add(const Duration(seconds: 1)); + nestedAsyncTracker.track(() { + time = time.add(const Duration(seconds: 2)); + }); + time = time.add(const Duration(seconds: 4)); + }); + expect(asyncTracker.isFinished, true); + expect(asyncTracker.startTime, startTime); + expect(asyncTracker.stopTime, time); + expect(asyncTracker.duration, const Duration(seconds: 7)); + expect(asyncTracker.innerDuration, const Duration(seconds: 5)); + expect(asyncTracker.slices.length, greaterThan(1)); + expect(nestedAsyncTracker.startTime.isAfter(startTime), true); + expect(nestedAsyncTracker.stopTime.isBefore(time), true); + expect(nestedAsyncTracker.duration, const Duration(seconds: 2)); + expect(nestedAsyncTracker.innerDuration, const Duration(seconds: 2)); + expect(nestedAsyncTracker.slices.length, 1); + }); + + test('Can exclude complex nested sync', () { + tracker = asyncTracker = AsyncTimeTracker(trackNested: false); + nestedAsyncTracker = AsyncTimeTracker(trackNested: false); + final nestedAsyncTracker2 = AsyncTimeTracker(trackNested: false); + scopedTrack(() { + time = time.add(const Duration(seconds: 1)); + nestedAsyncTracker.track(() { + time = time.add(const Duration(seconds: 2)); + nestedAsyncTracker2.track(() { + time = time.add(const Duration(seconds: 4)); + }); + time = time.add(const Duration(seconds: 8)); + }); + time = time.add(const Duration(seconds: 16)); + }); + expect(asyncTracker.isFinished, true); + expect(asyncTracker.startTime, startTime); + expect(asyncTracker.stopTime, time); + expect(asyncTracker.duration, const Duration(seconds: 31)); + expect(asyncTracker.innerDuration, const Duration(seconds: 17)); + expect(asyncTracker.slices.length, greaterThan(1)); + expect(nestedAsyncTracker.startTime.isAfter(startTime), true); + expect(nestedAsyncTracker.stopTime.isBefore(time), true); + expect(nestedAsyncTracker.duration, const Duration(seconds: 14)); + expect(nestedAsyncTracker.innerDuration, const Duration(seconds: 10)); + expect(nestedAsyncTracker.slices.length, greaterThan(1)); + expect(nestedAsyncTracker2.startTime.isAfter(startTime), true); + expect(nestedAsyncTracker2.stopTime.isBefore(time), true); + expect(nestedAsyncTracker2.duration, const Duration(seconds: 4)); + expect(nestedAsyncTracker2.innerDuration, const Duration(seconds: 4)); + expect(nestedAsyncTracker2.slices.length, 1); + }); + + test( + 'Can track all on grand-parent level and ' + 'exclude grand-childrens from parent', () { + tracker = asyncTracker = AsyncTimeTracker(trackNested: true); + nestedAsyncTracker = AsyncTimeTracker(trackNested: false); + final nestedAsyncTracker2 = AsyncTimeTracker(); + scopedTrack(() { + time = time.add(const Duration(seconds: 1)); + nestedAsyncTracker.track(() { + time = time.add(const Duration(seconds: 2)); + nestedAsyncTracker2.track(() { + time = time.add(const Duration(seconds: 4)); + }); + time = time.add(const Duration(seconds: 8)); + }); + time = time.add(const Duration(seconds: 16)); + }); + expect(asyncTracker.isFinished, true); + expect(asyncTracker.startTime, startTime); + expect(asyncTracker.stopTime, time); + expect(asyncTracker.duration, const Duration(seconds: 31)); + expect(asyncTracker.innerDuration, const Duration(seconds: 31)); + expect(asyncTracker.slices.length, 1); + expect(nestedAsyncTracker.startTime.isAfter(startTime), true); + expect(nestedAsyncTracker.stopTime.isBefore(time), true); + expect(nestedAsyncTracker.duration, const Duration(seconds: 14)); + expect(nestedAsyncTracker.innerDuration, const Duration(seconds: 10)); + expect(nestedAsyncTracker.slices.length, greaterThan(1)); + expect(nestedAsyncTracker2.startTime.isAfter(startTime), true); + expect(nestedAsyncTracker2.stopTime.isBefore(time), true); + expect(nestedAsyncTracker2.duration, const Duration(seconds: 4)); + expect(nestedAsyncTracker2.innerDuration, const Duration(seconds: 4)); + expect(nestedAsyncTracker2.slices.length, 1); + }); + + test('Can exclude nested async', () async { + tracker = asyncTracker = AsyncTimeTracker(trackNested: false); + await scopedTrack(() async { + time = time.add(const Duration(seconds: 1)); + await nestedAsyncTracker.track(() async { + time = time.add(const Duration(seconds: 2)); + await Future.value(); + time = time.add(const Duration(seconds: 4)); + await Future.value(); + time = time.add(const Duration(seconds: 8)); + }); + time = time.add(const Duration(seconds: 16)); + }); + expect(asyncTracker.isFinished, true); + expect(asyncTracker.startTime, startTime); + expect(asyncTracker.stopTime, time); + expect(asyncTracker.duration, const Duration(seconds: 31)); + expect(asyncTracker.innerDuration, const Duration(seconds: 17)); + expect(asyncTracker.slices.length, greaterThan(1)); + expect(nestedAsyncTracker.startTime.isAfter(startTime), true); + expect(nestedAsyncTracker.stopTime.isBefore(time), true); + expect(nestedAsyncTracker.duration, const Duration(seconds: 14)); + expect(nestedAsyncTracker.innerDuration, const Duration(seconds: 14)); + expect(nestedAsyncTracker.slices.length, greaterThan(1)); + }); + + test('Can handle callbacks in excluded nested async', () async { + tracker = asyncTracker = AsyncTimeTracker(trackNested: false); + await scopedTrack(() async { + time = time.add(const Duration(seconds: 1)); + final completer = Completer(); + final future = completer.future.then((_) { + time = time.add(const Duration(seconds: 2)); + }); + await nestedAsyncTracker.track(() async { + time = time.add(const Duration(seconds: 4)); + await Future.value(); + time = time.add(const Duration(seconds: 8)); + completer.complete(); + await future; + time = time.add(const Duration(seconds: 16)); + }); + time = time.add(const Duration(seconds: 32)); + }); + expect(asyncTracker.isFinished, true); + expect(asyncTracker.startTime, startTime); + expect(asyncTracker.stopTime, time); + expect(asyncTracker.duration, const Duration(seconds: 63)); + expect(asyncTracker.innerDuration, const Duration(seconds: 35)); + expect(asyncTracker.slices.length, greaterThan(1)); + expect(nestedAsyncTracker.startTime.isAfter(startTime), true); + expect(nestedAsyncTracker.stopTime.isBefore(time), true); + expect(nestedAsyncTracker.duration, const Duration(seconds: 30)); + expect(nestedAsyncTracker.innerDuration, const Duration(seconds: 28)); + expect(nestedAsyncTracker.slices.length, greaterThan(1)); + }); + }); +} diff --git a/pkgs/watcher/.gitignore b/pkgs/watcher/.gitignore new file mode 100644 index 000000000..ac98e87d1 --- /dev/null +++ b/pkgs/watcher/.gitignore @@ -0,0 +1,4 @@ +# Don’t commit the following directories created by pub. +.dart_tool +.packages +pubspec.lock diff --git a/pkgs/watcher/.test_config b/pkgs/watcher/.test_config new file mode 100644 index 000000000..531426abb --- /dev/null +++ b/pkgs/watcher/.test_config @@ -0,0 +1,5 @@ +{ + "test_package": { + "platforms": ["vm"] + } +} \ No newline at end of file diff --git a/pkgs/watcher/CHANGELOG.md b/pkgs/watcher/CHANGELOG.md new file mode 100644 index 000000000..ef3a7e2d3 --- /dev/null +++ b/pkgs/watcher/CHANGELOG.md @@ -0,0 +1,130 @@ +## 1.1.1 + +- Ensure `PollingFileWatcher.ready` completes for files that do not exist. +- Require Dart SDK `^3.1.0` +- Move to `dart-lang/tools` monorepo. + +## 1.1.0 + +- Require Dart SDK >= 3.0.0 +- Remove usage of redundant ConstructableFileSystemEvent classes. + +## 1.0.3-dev + +- Require Dart SDK >= 2.19 + +## 1.0.2 + +- Require Dart SDK >= 2.14 +- Ensure `DirectoryWatcher.ready` completes even when errors occur that close the watcher. +- Add markdown badges to the readme. + +## 1.0.1 + +* Drop package:pedantic and use package:lints instead. + +## 1.0.0 + +* Require Dart SDK >= 2.12 +* Add the ability to create custom Watcher types for specific file paths. + +## 0.9.7+15 + +* Fix a bug on Mac where modifying a directory with a path exactly matching a + prefix of a modified file would suppress change events for that file. + +## 0.9.7+14 + +* Prepare for breaking change in SDK where modified times for not found files + becomes meaningless instead of null. + +## 0.9.7+13 + +* Catch & forward `FileSystemException` from unexpectedly closed file watchers + on windows; the watcher will also be automatically restarted when this occurs. + +## 0.9.7+12 + +* Catch `FileSystemException` during `existsSync()` on Windows. +* Internal cleanup. + +## 0.9.7+11 + +* Fix an analysis hint. + +## 0.9.7+10 + +* Set max SDK version to `<3.0.0`, and adjust other dependencies. + +## 0.9.7+9 + +* Internal changes only. + +## 0.9.7+8 + +* Fix Dart 2.0 type issues on Mac and Windows. + +## 0.9.7+7 + +* Updates to support Dart 2.0 core library changes (wave 2.2). + See [issue 31847][sdk#31847] for details. + + [sdk#31847]: https://github.com/dart-lang/sdk/issues/31847 + + +## 0.9.7+6 + +* Internal changes only, namely removing dep on scheduled test. + +## 0.9.7+5 + +* Fix an analysis warning. + +## 0.9.7+4 + +* Declare support for `async` 2.0.0. + +## 0.9.7+3 + +* Fix a crashing bug on Linux. + +## 0.9.7+2 + +* Narrow the constraint on `async` to reflect the APIs this package is actually + using. + +## 0.9.7+1 + +* Fix all strong-mode warnings. + +## 0.9.7 + +* Fix a bug in `FileWatcher` where events could be added after watchers were + closed. + +## 0.9.6 + +* Add a `Watcher` interface that encompasses watching both files and + directories. + +* Add `FileWatcher` and `PollingFileWatcher` classes for watching changes to + individual files. + +* Deprecate `DirectoryWatcher.directory`. Use `DirectoryWatcher.path` instead. + +## 0.9.5 + +* Fix bugs where events could be added after watchers were closed. + +## 0.9.4 + +* Treat add events for known files as modifications instead of discarding them + on Mac OS. + +## 0.9.3 + +* Improved support for Windows via `WindowsDirectoryWatcher`. + +* Simplified `PollingDirectoryWatcher`. + +* Fixed bugs in `MacOSDirectoryWatcher` diff --git a/pkgs/watcher/LICENSE b/pkgs/watcher/LICENSE new file mode 100644 index 000000000..000cd7bec --- /dev/null +++ b/pkgs/watcher/LICENSE @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/watcher/README.md b/pkgs/watcher/README.md new file mode 100644 index 000000000..83a0324b4 --- /dev/null +++ b/pkgs/watcher/README.md @@ -0,0 +1,10 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/watcher.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/watcher.yaml) +[![pub package](https://img.shields.io/pub/v/watcher.svg)](https://pub.dev/packages/watcher) +[![package publisher](https://img.shields.io/pub/publisher/watcher.svg)](https://pub.dev/packages/watcher/publisher) + +A file system watcher. + +## What's this? + +`package:watcher` monitors changes to contents of directories and sends +notifications when files have been added, removed, or modified. diff --git a/pkgs/watcher/analysis_options.yaml b/pkgs/watcher/analysis_options.yaml new file mode 100644 index 000000000..d978f811c --- /dev/null +++ b/pkgs/watcher/analysis_options.yaml @@ -0,0 +1 @@ +include: package:dart_flutter_team_lints/analysis_options.yaml diff --git a/pkgs/watcher/benchmark/path_set.dart b/pkgs/watcher/benchmark/path_set.dart new file mode 100644 index 000000000..e7929d8ec --- /dev/null +++ b/pkgs/watcher/benchmark/path_set.dart @@ -0,0 +1,158 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Benchmarks for the PathSet class. +library; + +import 'dart:io'; +import 'dart:math' as math; + +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:path/path.dart' as p; +import 'package:watcher/src/path_set.dart'; + +final String root = Platform.isWindows ? r'C:\root' : '/root'; + +/// Base class for benchmarks on [PathSet]. +abstract class PathSetBenchmark extends BenchmarkBase { + PathSetBenchmark(String method) : super('PathSet.$method'); + + final PathSet pathSet = PathSet(root); + + /// Use a fixed [math.Random] with a constant seed to ensure the tests are + /// deterministic. + final math.Random random = math.Random(1234); + + /// Walks over a virtual directory [depth] levels deep invoking [callback] + /// for each "file". + /// + /// Each virtual directory contains ten entries: either subdirectories or + /// files. + void walkTree(int depth, void Function(String) callback) { + void recurse(String path, int remainingDepth) { + for (var i = 0; i < 10; i++) { + var padded = i.toString().padLeft(2, '0'); + if (remainingDepth == 0) { + callback(p.join(path, 'file_$padded.txt')); + } else { + var subdir = p.join(path, 'subdirectory_$padded'); + recurse(subdir, remainingDepth - 1); + } + } + } + + recurse(root, depth); + } +} + +class AddBenchmark extends PathSetBenchmark { + AddBenchmark() : super('add()'); + + final List paths = []; + + @override + void setup() { + // Make a bunch of paths in about the same order we expect to get them from + // Directory.list(). + walkTree(3, paths.add); + } + + @override + void run() { + for (var path in paths) { + pathSet.add(path); + } + } +} + +class ContainsBenchmark extends PathSetBenchmark { + ContainsBenchmark() : super('contains()'); + + final List paths = []; + + @override + void setup() { + // Add a bunch of paths to the set. + walkTree(3, (path) { + pathSet.add(path); + paths.add(path); + }); + + // Add some non-existent paths to test the false case. + for (var i = 0; i < 100; i++) { + paths.addAll([ + '/nope', + '/root/nope', + '/root/subdirectory_04/nope', + '/root/subdirectory_04/subdirectory_04/nope', + '/root/subdirectory_04/subdirectory_04/subdirectory_04/nope', + '/root/subdirectory_04/subdirectory_04/subdirectory_04/nope/file_04.txt', + ]); + } + } + + @override + void run() { + var contained = 0; + for (var path in paths) { + if (pathSet.contains(path)) contained++; + } + + if (contained != 10000) throw StateError('Wrong result: $contained'); + } +} + +class PathsBenchmark extends PathSetBenchmark { + PathsBenchmark() : super('toSet()'); + + @override + void setup() { + walkTree(3, pathSet.add); + } + + @override + void run() { + var count = 0; + for (var _ in pathSet.paths) { + count++; + } + + if (count != 10000) throw StateError('Wrong result: $count'); + } +} + +class RemoveBenchmark extends PathSetBenchmark { + RemoveBenchmark() : super('remove()'); + + final List paths = []; + + @override + void setup() { + // Make a bunch of paths. Do this here so that we don't spend benchmarked + // time synthesizing paths. + walkTree(3, (path) { + pathSet.add(path); + paths.add(path); + }); + + // Shuffle the paths so that we delete them in a random order that + // hopefully mimics real-world file system usage. Do the shuffling here so + // that we don't spend benchmarked time shuffling. + paths.shuffle(random); + } + + @override + void run() { + for (var path in paths) { + pathSet.remove(path); + } + } +} + +void main() { + AddBenchmark().report(); + ContainsBenchmark().report(); + PathsBenchmark().report(); + RemoveBenchmark().report(); +} diff --git a/pkgs/watcher/example/watch.dart b/pkgs/watcher/example/watch.dart new file mode 100644 index 000000000..37931d396 --- /dev/null +++ b/pkgs/watcher/example/watch.dart @@ -0,0 +1,19 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Watches the given directory and prints each modification to it. +library; + +import 'package:path/path.dart' as p; +import 'package:watcher/watcher.dart'; + +void main(List arguments) { + if (arguments.length != 1) { + print('Usage: watch '); + return; + } + + var watcher = DirectoryWatcher(p.absolute(arguments[0])); + watcher.events.listen(print); +} diff --git a/pkgs/watcher/lib/src/async_queue.dart b/pkgs/watcher/lib/src/async_queue.dart new file mode 100644 index 000000000..f6c76a9cb --- /dev/null +++ b/pkgs/watcher/lib/src/async_queue.dart @@ -0,0 +1,70 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; + +typedef ItemProcessor = Future Function(T item); + +/// A queue of items that are sequentially, asynchronously processed. +/// +/// Unlike [Stream.map] or [Stream.forEach], the callback used to process each +/// item returns a [Future], and it will not advance to the next item until the +/// current item is finished processing. +/// +/// Items can be added at any point in time and processing will be started as +/// needed. When all items are processed, it stops processing until more items +/// are added. +class AsyncQueue { + final _items = Queue(); + + /// Whether or not the queue is currently waiting on a processing future to + /// complete. + bool _isProcessing = false; + + /// The callback to invoke on each queued item. + /// + /// The next item in the queue will not be processed until the [Future] + /// returned by this completes. + final ItemProcessor _processor; + + /// The handler for errors thrown during processing. + /// + /// Used to avoid top-leveling asynchronous errors. + final void Function(Object, StackTrace) _errorHandler; + + AsyncQueue(this._processor, + {required void Function(Object, StackTrace) onError}) + : _errorHandler = onError; + + /// Enqueues [item] to be processed and starts asynchronously processing it + /// if a process isn't already running. + void add(T item) { + _items.add(item); + + // Start up the asynchronous processing if not already running. + if (_isProcessing) return; + _isProcessing = true; + + _processNextItem().catchError(_errorHandler); + } + + /// Removes all remaining items to be processed. + void clear() { + _items.clear(); + } + + /// Pulls the next item off [_items] and processes it. + /// + /// When complete, recursively calls itself to continue processing unless + /// the process was cancelled. + Future _processNextItem() async { + var item = _items.removeFirst(); + await _processor(item); + if (_items.isNotEmpty) return _processNextItem(); + + // We have drained the queue, stop processing and wait until something + // has been enqueued. + _isProcessing = false; + } +} diff --git a/pkgs/watcher/lib/src/custom_watcher_factory.dart b/pkgs/watcher/lib/src/custom_watcher_factory.dart new file mode 100644 index 000000000..fc4e3fb90 --- /dev/null +++ b/pkgs/watcher/lib/src/custom_watcher_factory.dart @@ -0,0 +1,88 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import '../watcher.dart'; + +/// A factory to produce custom watchers for specific file paths. +class _CustomWatcherFactory { + final String id; + final DirectoryWatcher? Function(String path, {Duration? pollingDelay}) + createDirectoryWatcher; + final FileWatcher? Function(String path, {Duration? pollingDelay}) + createFileWatcher; + + _CustomWatcherFactory( + this.id, this.createDirectoryWatcher, this.createFileWatcher); +} + +/// Registers a custom watcher. +/// +/// Each custom watcher must have a unique [id] and the same watcher may not be +/// registered more than once. +/// [createDirectoryWatcher] and [createFileWatcher] should return watchers for +/// the file paths they are able to handle. If the custom watcher is not able to +/// handle the path it should return null. +/// The paths handled by each custom watch may not overlap, at most one custom +/// matcher may return a non-null watcher for a given path. +/// +/// When a file or directory watcher is created the path is checked against each +/// registered custom watcher, and if exactly one custom watcher is available it +/// will be used instead of the default. +void registerCustomWatcher( + String id, + DirectoryWatcher? Function(String path, {Duration? pollingDelay})? + createDirectoryWatcher, + FileWatcher? Function(String path, {Duration? pollingDelay})? + createFileWatcher, +) { + if (_customWatcherFactories.containsKey(id)) { + throw ArgumentError('A custom watcher with id `$id` ' + 'has already been registered'); + } + _customWatcherFactories[id] = _CustomWatcherFactory( + id, + createDirectoryWatcher ?? (_, {pollingDelay}) => null, + createFileWatcher ?? (_, {pollingDelay}) => null); +} + +/// Tries to create a custom [DirectoryWatcher] and returns it. +/// +/// Returns `null` if no custom watcher was applicable and throws a [StateError] +/// if more than one was. +DirectoryWatcher? createCustomDirectoryWatcher(String path, + {Duration? pollingDelay}) { + DirectoryWatcher? customWatcher; + String? customFactoryId; + for (var watcherFactory in _customWatcherFactories.values) { + if (customWatcher != null) { + throw StateError('Two `CustomWatcherFactory`s applicable: ' + '`$customFactoryId` and `${watcherFactory.id}` for `$path`'); + } + customWatcher = + watcherFactory.createDirectoryWatcher(path, pollingDelay: pollingDelay); + customFactoryId = watcherFactory.id; + } + return customWatcher; +} + +/// Tries to create a custom [FileWatcher] and returns it. +/// +/// Returns `null` if no custom watcher was applicable and throws a [StateError] +/// if more than one was. +FileWatcher? createCustomFileWatcher(String path, {Duration? pollingDelay}) { + FileWatcher? customWatcher; + String? customFactoryId; + for (var watcherFactory in _customWatcherFactories.values) { + if (customWatcher != null) { + throw StateError('Two `CustomWatcherFactory`s applicable: ' + '`$customFactoryId` and `${watcherFactory.id}` for `$path`'); + } + customWatcher = + watcherFactory.createFileWatcher(path, pollingDelay: pollingDelay); + customFactoryId = watcherFactory.id; + } + return customWatcher; +} + +final _customWatcherFactories = {}; diff --git a/pkgs/watcher/lib/src/directory_watcher.dart b/pkgs/watcher/lib/src/directory_watcher.dart new file mode 100644 index 000000000..158b86b05 --- /dev/null +++ b/pkgs/watcher/lib/src/directory_watcher.dart @@ -0,0 +1,41 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:io'; + +import '../watcher.dart'; +import 'custom_watcher_factory.dart'; +import 'directory_watcher/linux.dart'; +import 'directory_watcher/mac_os.dart'; +import 'directory_watcher/windows.dart'; + +/// Watches the contents of a directory and emits [WatchEvent]s when something +/// in the directory has changed. +abstract class DirectoryWatcher implements Watcher { + /// The directory whose contents are being monitored. + @Deprecated('Expires in 1.0.0. Use DirectoryWatcher.path instead.') + String get directory; + + /// Creates a new [DirectoryWatcher] monitoring [directory]. + /// + /// If a native directory watcher is available for this platform, this will + /// use it. Otherwise, it will fall back to a [PollingDirectoryWatcher]. + /// + /// If [pollingDelay] is passed, it specifies the amount of time the watcher + /// will pause between successive polls of the directory contents. Making this + /// shorter will give more immediate feedback at the expense of doing more IO + /// and higher CPU usage. Defaults to one second. Ignored for non-polling + /// watchers. + factory DirectoryWatcher(String directory, {Duration? pollingDelay}) { + if (FileSystemEntity.isWatchSupported) { + var customWatcher = + createCustomDirectoryWatcher(directory, pollingDelay: pollingDelay); + if (customWatcher != null) return customWatcher; + if (Platform.isLinux) return LinuxDirectoryWatcher(directory); + if (Platform.isMacOS) return MacOSDirectoryWatcher(directory); + if (Platform.isWindows) return WindowsDirectoryWatcher(directory); + } + return PollingDirectoryWatcher(directory, pollingDelay: pollingDelay); + } +} diff --git a/pkgs/watcher/lib/src/directory_watcher/linux.dart b/pkgs/watcher/lib/src/directory_watcher/linux.dart new file mode 100644 index 000000000..cb1d07781 --- /dev/null +++ b/pkgs/watcher/lib/src/directory_watcher/linux.dart @@ -0,0 +1,294 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:async/async.dart'; + +import '../directory_watcher.dart'; +import '../path_set.dart'; +import '../resubscribable.dart'; +import '../utils.dart'; +import '../watch_event.dart'; + +/// Uses the inotify subsystem to watch for filesystem events. +/// +/// Inotify doesn't suport recursively watching subdirectories, nor does +/// [Directory.watch] polyfill that functionality. This class polyfills it +/// instead. +/// +/// This class also compensates for the non-inotify-specific issues of +/// [Directory.watch] producing multiple events for a single logical action +/// (issue 14372) and providing insufficient information about move events +/// (issue 14424). +class LinuxDirectoryWatcher extends ResubscribableWatcher + implements DirectoryWatcher { + @override + String get directory => path; + + LinuxDirectoryWatcher(String directory) + : super(directory, () => _LinuxDirectoryWatcher(directory)); +} + +class _LinuxDirectoryWatcher + implements DirectoryWatcher, ManuallyClosedWatcher { + @override + String get directory => _files.root; + @override + String get path => _files.root; + + @override + Stream get events => _eventsController.stream; + final _eventsController = StreamController.broadcast(); + + @override + bool get isReady => _readyCompleter.isCompleted; + + @override + Future get ready => _readyCompleter.future; + final _readyCompleter = Completer(); + + /// A stream group for the [Directory.watch] events of [path] and all its + /// subdirectories. + final _nativeEvents = StreamGroup(); + + /// All known files recursively within [path]. + final PathSet _files; + + /// [Directory.watch] streams for [path]'s subdirectories, indexed by name. + /// + /// A stream is in this map if and only if it's also in [_nativeEvents]. + final _subdirStreams = >{}; + + /// A set of all subscriptions that this watcher subscribes to. + /// + /// These are gathered together so that they may all be canceled when the + /// watcher is closed. + final _subscriptions = {}; + + _LinuxDirectoryWatcher(String path) : _files = PathSet(path) { + _nativeEvents.add(Directory(path) + .watch() + .transform(StreamTransformer.fromHandlers(handleDone: (sink) { + // Handle the done event here rather than in the call to [_listen] because + // [innerStream] won't close until we close the [StreamGroup]. However, if + // we close the [StreamGroup] here, we run the risk of new-directory + // events being fired after the group is closed, since batching delays + // those events. See b/30768513. + _onDone(); + }))); + + // Batch the inotify changes together so that we can dedup events. + var innerStream = _nativeEvents.stream.batchEvents(); + _listen(innerStream, _onBatch, + onError: (Object error, StackTrace stackTrace) { + // Guarantee that ready always completes. + if (!isReady) { + _readyCompleter.complete(); + } + _eventsController.addError(error, stackTrace); + }); + + _listen( + Directory(path).list(recursive: true), + (FileSystemEntity entity) { + if (entity is Directory) { + _watchSubdir(entity.path); + } else { + _files.add(entity.path); + } + }, + onError: _emitError, + onDone: () { + if (!isReady) { + _readyCompleter.complete(); + } + }, + cancelOnError: true, + ); + } + + @override + void close() { + for (var subscription in _subscriptions) { + subscription.cancel(); + } + + _subscriptions.clear(); + _subdirStreams.clear(); + _files.clear(); + _nativeEvents.close(); + _eventsController.close(); + } + + /// Watch a subdirectory of [directory] for changes. + void _watchSubdir(String path) { + // TODO(nweiz): Right now it's possible for the watcher to emit an event for + // a file before the directory list is complete. This could lead to the user + // seeing a MODIFY or REMOVE event for a file before they see an ADD event, + // which is bad. We should handle that. + // + // One possibility is to provide a general means (e.g. + // `DirectoryWatcher.eventsAndExistingFiles`) to tell a watcher to emit + // events for all the files that already exist. This would be useful for + // top-level clients such as barback as well, and could be implemented with + // a wrapper similar to how listening/canceling works now. + + // TODO(nweiz): Catch any errors here that indicate that the directory in + // question doesn't exist and silently stop watching it instead of + // propagating the errors. + var stream = Directory(path).watch(); + _subdirStreams[path] = stream; + _nativeEvents.add(stream); + } + + /// The callback that's run when a batch of changes comes in. + void _onBatch(List batch) { + var files = {}; + var dirs = {}; + var changed = {}; + + // inotify event batches are ordered by occurrence, so we treat them as a + // log of what happened to a file. We only emit events based on the + // difference between the state before the batch and the state after it, not + // the intermediate state. + for (var event in batch) { + // If the watched directory is deleted or moved, we'll get a deletion + // event for it. Ignore it; we handle closing [this] when the underlying + // stream is closed. + if (event.path == path) continue; + + changed.add(event.path); + + if (event is FileSystemMoveEvent) { + files.remove(event.path); + dirs.remove(event.path); + + var destination = event.destination; + if (destination == null) continue; + + changed.add(destination); + if (event.isDirectory) { + files.remove(destination); + dirs.add(destination); + } else { + files.add(destination); + dirs.remove(destination); + } + } else if (event is FileSystemDeleteEvent) { + files.remove(event.path); + dirs.remove(event.path); + } else if (event.isDirectory) { + files.remove(event.path); + dirs.add(event.path); + } else { + files.add(event.path); + dirs.remove(event.path); + } + } + + _applyChanges(files, dirs, changed); + } + + /// Applies the net changes computed for a batch. + /// + /// The [files] and [dirs] sets contain the files and directories that now + /// exist, respectively. The [changed] set contains all files and directories + /// that have changed (including being removed), and so is a superset of + /// [files] and [dirs]. + void _applyChanges(Set files, Set dirs, Set changed) { + for (var path in changed) { + var stream = _subdirStreams.remove(path); + if (stream != null) _nativeEvents.add(stream); + + // Unless [path] was a file and still is, emit REMOVE events for it or its + // contents, + if (files.contains(path) && _files.contains(path)) continue; + for (var file in _files.remove(path)) { + _emitEvent(ChangeType.REMOVE, file); + } + } + + for (var file in files) { + if (_files.contains(file)) { + _emitEvent(ChangeType.MODIFY, file); + } else { + _emitEvent(ChangeType.ADD, file); + _files.add(file); + } + } + + for (var dir in dirs) { + _watchSubdir(dir); + _addSubdir(dir); + } + } + + /// Emits [ChangeType.ADD] events for the recursive contents of [path]. + void _addSubdir(String path) { + _listen(Directory(path).list(recursive: true), (FileSystemEntity entity) { + if (entity is Directory) { + _watchSubdir(entity.path); + } else { + _files.add(entity.path); + _emitEvent(ChangeType.ADD, entity.path); + } + }, onError: (Object error, StackTrace stackTrace) { + // Ignore an exception caused by the dir not existing. It's fine if it + // was added and then quickly removed. + if (error is FileSystemException) return; + + _emitError(error, stackTrace); + }, cancelOnError: true); + } + + /// Handles the underlying event stream closing, indicating that the directory + /// being watched was removed. + void _onDone() { + // Most of the time when a directory is removed, its contents will get + // individual REMOVE events before the watch stream is closed -- in that + // case, [_files] will be empty here. However, if the directory's removal is + // caused by a MOVE, we need to manually emit events. + if (isReady) { + for (var file in _files.paths) { + _emitEvent(ChangeType.REMOVE, file); + } + } + + close(); + } + + /// Emits a [WatchEvent] with [type] and [path] if this watcher is in a state + /// to emit events. + void _emitEvent(ChangeType type, String path) { + if (!isReady) return; + if (_eventsController.isClosed) return; + _eventsController.add(WatchEvent(type, path)); + } + + /// Emit an error, then close the watcher. + void _emitError(Object error, StackTrace stackTrace) { + // Guarantee that ready always completes. + if (!isReady) { + _readyCompleter.complete(); + } + _eventsController.addError(error, stackTrace); + close(); + } + + /// Like [Stream.listen], but automatically adds the subscription to + /// [_subscriptions] so that it can be canceled when [close] is called. + void _listen(Stream stream, void Function(T) onData, + {Function? onError, + void Function()? onDone, + bool cancelOnError = false}) { + late StreamSubscription subscription; + subscription = stream.listen(onData, onError: onError, onDone: () { + _subscriptions.remove(subscription); + onDone?.call(); + }, cancelOnError: cancelOnError); + _subscriptions.add(subscription); + } +} diff --git a/pkgs/watcher/lib/src/directory_watcher/mac_os.dart b/pkgs/watcher/lib/src/directory_watcher/mac_os.dart new file mode 100644 index 000000000..b46138347 --- /dev/null +++ b/pkgs/watcher/lib/src/directory_watcher/mac_os.dart @@ -0,0 +1,410 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../directory_watcher.dart'; +import '../path_set.dart'; +import '../resubscribable.dart'; +import '../utils.dart'; +import '../watch_event.dart'; + +/// Uses the FSEvents subsystem to watch for filesystem events. +/// +/// FSEvents has two main idiosyncrasies that this class works around. First, it +/// will occasionally report events that occurred before the filesystem watch +/// was initiated. Second, if multiple events happen to the same file in close +/// succession, it won't report them in the order they occurred. See issue +/// 14373. +/// +/// This also works around issues 16003 and 14849 in the implementation of +/// [Directory.watch]. +class MacOSDirectoryWatcher extends ResubscribableWatcher + implements DirectoryWatcher { + @override + String get directory => path; + + MacOSDirectoryWatcher(String directory) + : super(directory, () => _MacOSDirectoryWatcher(directory)); +} + +class _MacOSDirectoryWatcher + implements DirectoryWatcher, ManuallyClosedWatcher { + @override + String get directory => path; + @override + final String path; + + @override + Stream get events => _eventsController.stream; + final _eventsController = StreamController.broadcast(); + + @override + bool get isReady => _readyCompleter.isCompleted; + + @override + Future get ready => _readyCompleter.future; + final _readyCompleter = Completer(); + + /// The set of files that are known to exist recursively within the watched + /// directory. + /// + /// The state of files on the filesystem is compared against this to determine + /// the real change that occurred when working around issue 14373. This is + /// also used to emit REMOVE events when subdirectories are moved out of the + /// watched directory. + final PathSet _files; + + /// The subscription to the stream returned by [Directory.watch]. + /// + /// This is separate from [_listSubscriptions] because this stream + /// occasionally needs to be resubscribed in order to work around issue 14849. + StreamSubscription>? _watchSubscription; + + /// The subscription to the [Directory.list] call for the initial listing of + /// the directory to determine its initial state. + StreamSubscription? _initialListSubscription; + + /// The subscriptions to [Directory.list] calls for listing the contents of a + /// subdirectory that was moved into the watched directory. + final _listSubscriptions = >{}; + + /// The timer for tracking how long we wait for an initial batch of bogus + /// events (see issue 14373). + late Timer _bogusEventTimer; + + _MacOSDirectoryWatcher(this.path) : _files = PathSet(path) { + _startWatch(); + + // Before we're ready to emit events, wait for [_listDir] to complete and + // for enough time to elapse that if bogus events (issue 14373) would be + // emitted, they will be. + // + // If we do receive a batch of events, [_onBatch] will ensure that these + // futures don't fire and that the directory is re-listed. + Future.wait([_listDir(), _waitForBogusEvents()]).then((_) { + if (!isReady) { + _readyCompleter.complete(); + } + }); + } + + @override + void close() { + _watchSubscription?.cancel(); + _initialListSubscription?.cancel(); + _watchSubscription = null; + _initialListSubscription = null; + + for (var subscription in _listSubscriptions) { + subscription.cancel(); + } + _listSubscriptions.clear(); + + _eventsController.close(); + } + + /// The callback that's run when [Directory.watch] emits a batch of events. + void _onBatch(List batch) { + // If we get a batch of events before we're ready to begin emitting events, + // it's probable that it's a batch of pre-watcher events (see issue 14373). + // Ignore those events and re-list the directory. + if (!isReady) { + // Cancel the timer because bogus events only occur in the first batch, so + // we can fire [ready] as soon as we're done listing the directory. + _bogusEventTimer.cancel(); + _listDir().then((_) { + if (!isReady) { + _readyCompleter.complete(); + } + }); + return; + } + + _sortEvents(batch).forEach((path, eventSet) { + var canonicalEvent = _canonicalEvent(eventSet); + var events = canonicalEvent == null + ? _eventsBasedOnFileSystem(path) + : [canonicalEvent]; + + for (var event in events) { + if (event is FileSystemCreateEvent) { + if (!event.isDirectory) { + // If we already know about the file, treat it like a modification. + // This can happen if a file is copied on top of an existing one. + // We'll see an ADD event for the latter file when from the user's + // perspective, the file's contents just changed. + var type = + _files.contains(path) ? ChangeType.MODIFY : ChangeType.ADD; + + _emitEvent(type, path); + _files.add(path); + continue; + } + + if (_files.containsDir(path)) continue; + + var stream = Directory(path).list(recursive: true); + var subscription = stream.listen((entity) { + if (entity is Directory) return; + if (_files.contains(path)) return; + + _emitEvent(ChangeType.ADD, entity.path); + _files.add(entity.path); + }, cancelOnError: true); + subscription.onDone(() { + _listSubscriptions.remove(subscription); + }); + subscription.onError(_emitError); + _listSubscriptions.add(subscription); + } else if (event is FileSystemModifyEvent) { + assert(!event.isDirectory); + _emitEvent(ChangeType.MODIFY, path); + } else { + assert(event is FileSystemDeleteEvent); + for (var removedPath in _files.remove(path)) { + _emitEvent(ChangeType.REMOVE, removedPath); + } + } + } + }); + } + + /// Sort all the events in a batch into sets based on their path. + /// + /// A single input event may result in multiple events in the returned map; + /// for example, a MOVE event becomes a DELETE event for the source and a + /// CREATE event for the destination. + /// + /// The returned events won't contain any [FileSystemMoveEvent]s, nor will it + /// contain any events relating to [path]. + Map> _sortEvents(List batch) { + var eventsForPaths = >{}; + + // FSEvents can report past events, including events on the root directory + // such as it being created. We want to ignore these. If the directory is + // really deleted, that's handled by [_onDone]. + batch = batch.where((event) => event.path != path).toList(); + + // Events within directories that already have events are superfluous; the + // directory's full contents will be examined anyway, so we ignore such + // events. Emitting them could cause useless or out-of-order events. + var directories = unionAll(batch.map((event) { + if (!event.isDirectory) return {}; + if (event is FileSystemMoveEvent) { + var destination = event.destination; + if (destination != null) { + return {event.path, destination}; + } + } + return {event.path}; + })); + + bool isInModifiedDirectory(String path) => + directories.any((dir) => path != dir && p.isWithin(dir, path)); + + void addEvent(String path, FileSystemEvent event) { + if (isInModifiedDirectory(path)) return; + eventsForPaths.putIfAbsent(path, () => {}).add(event); + } + + for (var event in batch) { + // The Mac OS watcher doesn't emit move events. See issue 14806. + assert(event is! FileSystemMoveEvent); + addEvent(event.path, event); + } + + return eventsForPaths; + } + + /// Returns the canonical event from a batch of events on the same path, if + /// one exists. + /// + /// If [batch] doesn't contain any contradictory events (e.g. DELETE and + /// CREATE, or events with different values for `isDirectory`), this returns a + /// single event that describes what happened to the path in question. + /// + /// If [batch] does contain contradictory events, this returns `null` to + /// indicate that the state of the path on the filesystem should be checked to + /// determine what occurred. + FileSystemEvent? _canonicalEvent(Set batch) { + // An empty batch indicates that we've learned earlier that the batch is + // contradictory (e.g. because of a move). + if (batch.isEmpty) return null; + + var type = batch.first.type; + var isDir = batch.first.isDirectory; + var hadModifyEvent = false; + + for (var event in batch.skip(1)) { + // If one event reports that the file is a directory and another event + // doesn't, that's a contradiction. + if (isDir != event.isDirectory) return null; + + // Modify events don't contradict either CREATE or REMOVE events. We can + // safely assume the file was modified after a CREATE or before the + // REMOVE; otherwise there will also be a REMOVE or CREATE event + // (respectively) that will be contradictory. + if (event is FileSystemModifyEvent) { + hadModifyEvent = true; + continue; + } + assert(event is FileSystemCreateEvent || event is FileSystemDeleteEvent); + + // If we previously thought this was a MODIFY, we now consider it to be a + // CREATE or REMOVE event. This is safe for the same reason as above. + if (type == FileSystemEvent.modify) { + type = event.type; + continue; + } + + // A CREATE event contradicts a REMOVE event and vice versa. + assert(type == FileSystemEvent.create || type == FileSystemEvent.delete); + if (type != event.type) return null; + } + + // If we got a CREATE event for a file we already knew about, that comes + // from FSEvents reporting an add that happened prior to the watch + // beginning. If we also received a MODIFY event, we want to report that, + // but not the CREATE. + if (type == FileSystemEvent.create && + hadModifyEvent && + _files.contains(batch.first.path)) { + type = FileSystemEvent.modify; + } + + switch (type) { + case FileSystemEvent.create: + // Issue 16003 means that a CREATE event for a directory can indicate + // that the directory was moved and then re-created. + // [_eventsBasedOnFileSystem] will handle this correctly by producing a + // DELETE event followed by a CREATE event if the directory exists. + if (isDir) return null; + return FileSystemCreateEvent(batch.first.path, false); + case FileSystemEvent.delete: + return FileSystemDeleteEvent(batch.first.path, isDir); + case FileSystemEvent.modify: + return FileSystemModifyEvent(batch.first.path, isDir, false); + default: + throw StateError('unreachable'); + } + } + + /// Returns one or more events that describe the change between the last known + /// state of [path] and its current state on the filesystem. + /// + /// This returns a list whose order should be reflected in the events emitted + /// to the user, unlike the batched events from [Directory.watch]. The + /// returned list may be empty, indicating that no changes occurred to [path] + /// (probably indicating that it was created and then immediately deleted). + List _eventsBasedOnFileSystem(String path) { + var fileExisted = _files.contains(path); + var dirExisted = _files.containsDir(path); + var fileExists = File(path).existsSync(); + var dirExists = Directory(path).existsSync(); + + var events = []; + if (fileExisted) { + if (fileExists) { + events.add(FileSystemModifyEvent(path, false, false)); + } else { + events.add(FileSystemDeleteEvent(path, false)); + } + } else if (dirExisted) { + if (dirExists) { + // If we got contradictory events for a directory that used to exist and + // still exists, we need to rescan the whole thing in case it was + // replaced with a different directory. + events.add(FileSystemDeleteEvent(path, true)); + events.add(FileSystemCreateEvent(path, true)); + } else { + events.add(FileSystemDeleteEvent(path, true)); + } + } + + if (!fileExisted && fileExists) { + events.add(FileSystemCreateEvent(path, false)); + } else if (!dirExisted && dirExists) { + events.add(FileSystemCreateEvent(path, true)); + } + + return events; + } + + /// The callback that's run when the [Directory.watch] stream is closed. + void _onDone() { + _watchSubscription = null; + + // If the directory still exists and we're still expecting bogus events, + // this is probably issue 14849 rather than a real close event. We should + // just restart the watcher. + if (!isReady && Directory(path).existsSync()) { + _startWatch(); + return; + } + + // FSEvents can fail to report the contents of the directory being removed + // when the directory itself is removed, so we need to manually mark the + // files as removed. + for (var file in _files.paths) { + _emitEvent(ChangeType.REMOVE, file); + } + _files.clear(); + close(); + } + + /// Start or restart the underlying [Directory.watch] stream. + void _startWatch() { + // Batch the FSEvent changes together so that we can dedup events. + var innerStream = Directory(path).watch(recursive: true).batchEvents(); + _watchSubscription = innerStream.listen(_onBatch, + onError: _eventsController.addError, onDone: _onDone); + } + + /// Starts or restarts listing the watched directory to get an initial picture + /// of its state. + Future _listDir() { + assert(!isReady); + _initialListSubscription?.cancel(); + + _files.clear(); + var completer = Completer(); + var stream = Directory(path).list(recursive: true); + _initialListSubscription = stream.listen((entity) { + if (entity is! Directory) _files.add(entity.path); + }, onError: _emitError, onDone: completer.complete, cancelOnError: true); + return completer.future; + } + + /// Wait 200ms for a batch of bogus events (issue 14373) to come in. + /// + /// 200ms is short in terms of human interaction, but longer than any Mac OS + /// watcher tests take on the bots, so it should be safe to assume that any + /// bogus events will be signaled in that time frame. + Future _waitForBogusEvents() { + var completer = Completer(); + _bogusEventTimer = + Timer(const Duration(milliseconds: 200), completer.complete); + return completer.future; + } + + /// Emit an event with the given [type] and [path]. + void _emitEvent(ChangeType type, String path) { + if (!isReady) return; + _eventsController.add(WatchEvent(type, path)); + } + + /// Emit an error, then close the watcher. + void _emitError(Object error, StackTrace stackTrace) { + // Guarantee that ready always completes. + if (!isReady) { + _readyCompleter.complete(); + } + _eventsController.addError(error, stackTrace); + close(); + } +} diff --git a/pkgs/watcher/lib/src/directory_watcher/polling.dart b/pkgs/watcher/lib/src/directory_watcher/polling.dart new file mode 100644 index 000000000..207679b1a --- /dev/null +++ b/pkgs/watcher/lib/src/directory_watcher/polling.dart @@ -0,0 +1,191 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import '../async_queue.dart'; +import '../directory_watcher.dart'; +import '../resubscribable.dart'; +import '../stat.dart'; +import '../utils.dart'; +import '../watch_event.dart'; + +/// Periodically polls a directory for changes. +class PollingDirectoryWatcher extends ResubscribableWatcher + implements DirectoryWatcher { + @override + String get directory => path; + + /// Creates a new polling watcher monitoring [directory]. + /// + /// If [pollingDelay] is passed, it specifies the amount of time the watcher + /// will pause between successive polls of the directory contents. Making this + /// shorter will give more immediate feedback at the expense of doing more IO + /// and higher CPU usage. Defaults to one second. + PollingDirectoryWatcher(String directory, {Duration? pollingDelay}) + : super(directory, () { + return _PollingDirectoryWatcher( + directory, pollingDelay ?? const Duration(seconds: 1)); + }); +} + +class _PollingDirectoryWatcher + implements DirectoryWatcher, ManuallyClosedWatcher { + @override + String get directory => path; + @override + final String path; + + @override + Stream get events => _events.stream; + final _events = StreamController.broadcast(); + + @override + bool get isReady => _readyCompleter.isCompleted; + + @override + Future get ready => _readyCompleter.future; + final _readyCompleter = Completer(); + + /// The amount of time the watcher pauses between successive polls of the + /// directory contents. + final Duration _pollingDelay; + + /// The previous modification times of the files in the directory. + /// + /// Used to tell which files have been modified. + final _lastModifieds = {}; + + /// The subscription used while [directory] is being listed. + /// + /// Will be `null` if a list is not currently happening. + StreamSubscription? _listSubscription; + + /// The queue of files waiting to be processed to see if they have been + /// modified. + /// + /// Processing a file is asynchronous, as is listing the directory, so the + /// queue exists to let each of those proceed at their own rate. The lister + /// will enqueue files as quickly as it can. Meanwhile, files are dequeued + /// and processed sequentially. + late final AsyncQueue _filesToProcess = + AsyncQueue(_processFile, onError: (error, stackTrace) { + if (!_events.isClosed) _events.addError(error, stackTrace); + }); + + /// The set of files that have been seen in the current directory listing. + /// + /// Used to tell which files have been removed: files that are in + /// [_lastModifieds] but not in here when a poll completes have been removed. + final _polledFiles = {}; + + _PollingDirectoryWatcher(this.path, this._pollingDelay) { + _poll(); + } + + @override + void close() { + _events.close(); + + // If we're in the middle of listing the directory, stop. + _listSubscription?.cancel(); + + // Don't process any remaining files. + _filesToProcess.clear(); + _polledFiles.clear(); + _lastModifieds.clear(); + } + + /// Scans the contents of the directory once to see which files have been + /// added, removed, and modified. + void _poll() { + _filesToProcess.clear(); + _polledFiles.clear(); + + void endListing() { + assert(!_events.isClosed); + _listSubscription = null; + + // Null tells the queue consumer that we're done listing. + _filesToProcess.add(null); + } + + var stream = Directory(path).list(recursive: true); + _listSubscription = stream.listen((entity) { + assert(!_events.isClosed); + + if (entity is! File) return; + _filesToProcess.add(entity.path); + }, onError: (Object error, StackTrace stackTrace) { + // Guarantee that ready always completes. + if (!isReady) { + _readyCompleter.complete(); + } + if (!isDirectoryNotFoundException(error)) { + // It's some unknown error. Pipe it over to the event stream so the + // user can see it. + _events.addError(error, stackTrace); + } + + // When an error occurs, we end the listing normally, which has the + // desired effect of marking all files that were in the directory as + // being removed. + endListing(); + }, onDone: endListing, cancelOnError: true); + } + + /// Processes [file] to determine if it has been modified since the last + /// time it was scanned. + Future _processFile(String? file) async { + // `null` is the sentinel which means the directory listing is complete. + if (file == null) { + await _completePoll(); + return; + } + + final modified = await modificationTime(file); + + if (_events.isClosed) return; + + var lastModified = _lastModifieds[file]; + + // If its modification time hasn't changed, assume the file is unchanged. + if (lastModified != null && lastModified == modified) { + // The file is still here. + _polledFiles.add(file); + return; + } + + if (_events.isClosed) return; + + _lastModifieds[file] = modified; + _polledFiles.add(file); + + // Only notify if we're ready to emit events. + if (!isReady) return; + + var type = lastModified == null ? ChangeType.ADD : ChangeType.MODIFY; + _events.add(WatchEvent(type, file)); + } + + /// After the directory listing is complete, this determines which files were + /// removed and then restarts the next poll. + Future _completePoll() async { + // Any files that were not seen in the last poll but that we have a + // status for must have been removed. + var removedFiles = _lastModifieds.keys.toSet().difference(_polledFiles); + for (var removed in removedFiles) { + if (isReady) _events.add(WatchEvent(ChangeType.REMOVE, removed)); + _lastModifieds.remove(removed); + } + + if (!isReady) _readyCompleter.complete(); + + // Wait and then poll again. + await Future.delayed(_pollingDelay); + if (_events.isClosed) return; + _poll(); + } +} diff --git a/pkgs/watcher/lib/src/directory_watcher/windows.dart b/pkgs/watcher/lib/src/directory_watcher/windows.dart new file mode 100644 index 000000000..d1c98be1f --- /dev/null +++ b/pkgs/watcher/lib/src/directory_watcher/windows.dart @@ -0,0 +1,437 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +// TODO(rnystrom): Merge with mac_os version. + +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../directory_watcher.dart'; +import '../path_set.dart'; +import '../resubscribable.dart'; +import '../utils.dart'; +import '../watch_event.dart'; + +class WindowsDirectoryWatcher extends ResubscribableWatcher + implements DirectoryWatcher { + @override + String get directory => path; + + WindowsDirectoryWatcher(String directory) + : super(directory, () => _WindowsDirectoryWatcher(directory)); +} + +class _EventBatcher { + static const Duration _batchDelay = Duration(milliseconds: 100); + final List events = []; + Timer? timer; + + void addEvent(FileSystemEvent event, void Function() callback) { + events.add(event); + timer?.cancel(); + timer = Timer(_batchDelay, callback); + } + + void cancelTimer() { + timer?.cancel(); + } +} + +class _WindowsDirectoryWatcher + implements DirectoryWatcher, ManuallyClosedWatcher { + @override + String get directory => path; + @override + final String path; + + @override + Stream get events => _eventsController.stream; + final _eventsController = StreamController.broadcast(); + + @override + bool get isReady => _readyCompleter.isCompleted; + + @override + Future get ready => _readyCompleter.future; + final _readyCompleter = Completer(); + + final Map _eventBatchers = + HashMap(); + + /// The set of files that are known to exist recursively within the watched + /// directory. + /// + /// The state of files on the filesystem is compared against this to determine + /// the real change that occurred. This is also used to emit REMOVE events + /// when subdirectories are moved out of the watched directory. + final PathSet _files; + + /// The subscription to the stream returned by [Directory.watch]. + StreamSubscription? _watchSubscription; + + /// The subscription to the stream returned by [Directory.watch] of the + /// parent directory to [directory]. This is needed to detect changes to + /// [directory], as they are not included on Windows. + StreamSubscription? _parentWatchSubscription; + + /// The subscription to the [Directory.list] call for the initial listing of + /// the directory to determine its initial state. + StreamSubscription? _initialListSubscription; + + /// The subscriptions to the [Directory.list] calls for listing the contents + /// of subdirectories that were moved into the watched directory. + final Set> _listSubscriptions = + HashSet>(); + + _WindowsDirectoryWatcher(this.path) : _files = PathSet(path) { + // Before we're ready to emit events, wait for [_listDir] to complete. + _listDir().then((_) { + _startWatch(); + _startParentWatcher(); + if (!isReady) { + _readyCompleter.complete(); + } + }); + } + + @override + void close() { + _watchSubscription?.cancel(); + _parentWatchSubscription?.cancel(); + _initialListSubscription?.cancel(); + for (var sub in _listSubscriptions) { + sub.cancel(); + } + _listSubscriptions.clear(); + for (var batcher in _eventBatchers.values) { + batcher.cancelTimer(); + } + _eventBatchers.clear(); + _watchSubscription = null; + _parentWatchSubscription = null; + _initialListSubscription = null; + _eventsController.close(); + } + + /// On Windows, if [directory] is deleted, we will not receive any event. + /// + /// Instead, we add a watcher on the parent folder (if any), that can notify + /// us about [path]. This also includes events such as moves. + void _startParentWatcher() { + var absoluteDir = p.absolute(path); + var parent = p.dirname(absoluteDir); + // Check if [path] is already the root directory. + if (FileSystemEntity.identicalSync(parent, path)) return; + var parentStream = Directory(parent).watch(recursive: false); + _parentWatchSubscription = parentStream.listen((event) { + // Only look at events for 'directory'. + if (p.basename(event.path) != p.basename(absoluteDir)) return; + // Test if the directory is removed. FileSystemEntity.typeSync will + // return NOT_FOUND if it's unable to decide upon the type, including + // access denied issues, which may happen when the directory is deleted. + // FileSystemMoveEvent and FileSystemDeleteEvent events will always mean + // the directory is now gone. + if (event is FileSystemMoveEvent || + event is FileSystemDeleteEvent || + (FileSystemEntity.typeSync(path) == FileSystemEntityType.notFound)) { + for (var path in _files.paths) { + _emitEvent(ChangeType.REMOVE, path); + } + _files.clear(); + close(); + } + }, onError: (error) { + // Ignore errors, simply close the stream. The user listens on + // [directory], and while it can fail to listen on the parent, we may + // still be able to listen on the path requested. + _parentWatchSubscription?.cancel(); + _parentWatchSubscription = null; + }); + } + + void _onEvent(FileSystemEvent event) { + assert(isReady); + final batcher = _eventBatchers.putIfAbsent(event.path, _EventBatcher.new); + batcher.addEvent(event, () { + _eventBatchers.remove(event.path); + _onBatch(batcher.events); + }); + } + + /// The callback that's run when [Directory.watch] emits a batch of events. + void _onBatch(List batch) { + _sortEvents(batch).forEach((path, eventSet) { + var canonicalEvent = _canonicalEvent(eventSet); + var events = canonicalEvent == null + ? _eventsBasedOnFileSystem(path) + : [canonicalEvent]; + + for (var event in events) { + if (event is FileSystemCreateEvent) { + if (!event.isDirectory) { + if (_files.contains(path)) continue; + + _emitEvent(ChangeType.ADD, path); + _files.add(path); + continue; + } + + if (_files.containsDir(path)) continue; + + var stream = Directory(path).list(recursive: true); + var subscription = stream.listen((entity) { + if (entity is Directory) return; + if (_files.contains(path)) return; + + _emitEvent(ChangeType.ADD, entity.path); + _files.add(entity.path); + }, cancelOnError: true); + subscription.onDone(() { + _listSubscriptions.remove(subscription); + }); + subscription.onError((Object e, StackTrace stackTrace) { + _listSubscriptions.remove(subscription); + _emitError(e, stackTrace); + }); + _listSubscriptions.add(subscription); + } else if (event is FileSystemModifyEvent) { + if (!event.isDirectory) { + _emitEvent(ChangeType.MODIFY, path); + } + } else { + assert(event is FileSystemDeleteEvent); + for (var removedPath in _files.remove(path)) { + _emitEvent(ChangeType.REMOVE, removedPath); + } + } + } + }); + } + + /// Sort all the events in a batch into sets based on their path. + /// + /// A single input event may result in multiple events in the returned map; + /// for example, a MOVE event becomes a DELETE event for the source and a + /// CREATE event for the destination. + /// + /// The returned events won't contain any [FileSystemMoveEvent]s, nor will it + /// contain any events relating to [path]. + Map> _sortEvents(List batch) { + var eventsForPaths = >{}; + + // Events within directories that already have events are superfluous; the + // directory's full contents will be examined anyway, so we ignore such + // events. Emitting them could cause useless or out-of-order events. + var directories = unionAll(batch.map((event) { + if (!event.isDirectory) return {}; + if (event is FileSystemMoveEvent) { + var destination = event.destination; + if (destination != null) { + return {event.path, destination}; + } + } + return {event.path}; + })); + + bool isInModifiedDirectory(String path) => + directories.any((dir) => path != dir && p.isWithin(dir, path)); + + void addEvent(String path, FileSystemEvent event) { + if (isInModifiedDirectory(path)) return; + eventsForPaths.putIfAbsent(path, () => {}).add(event); + } + + for (var event in batch) { + if (event is FileSystemMoveEvent) { + var destination = event.destination; + if (destination != null) { + addEvent(destination, event); + } + } + addEvent(event.path, event); + } + + return eventsForPaths; + } + + /// Returns the canonical event from a batch of events on the same path, if + /// one exists. + /// + /// If [batch] doesn't contain any contradictory events (e.g. DELETE and + /// CREATE, or events with different values for `isDirectory`), this returns a + /// single event that describes what happened to the path in question. + /// + /// If [batch] does contain contradictory events, this returns `null` to + /// indicate that the state of the path on the filesystem should be checked to + /// determine what occurred. + FileSystemEvent? _canonicalEvent(Set batch) { + // An empty batch indicates that we've learned earlier that the batch is + // contradictory (e.g. because of a move). + if (batch.isEmpty) return null; + + var type = batch.first.type; + var isDir = batch.first.isDirectory; + + for (var event in batch.skip(1)) { + // If one event reports that the file is a directory and another event + // doesn't, that's a contradiction. + if (isDir != event.isDirectory) return null; + + // Modify events don't contradict either CREATE or REMOVE events. We can + // safely assume the file was modified after a CREATE or before the + // REMOVE; otherwise there will also be a REMOVE or CREATE event + // (respectively) that will be contradictory. + if (event is FileSystemModifyEvent) continue; + assert(event is FileSystemCreateEvent || + event is FileSystemDeleteEvent || + event is FileSystemMoveEvent); + + // If we previously thought this was a MODIFY, we now consider it to be a + // CREATE or REMOVE event. This is safe for the same reason as above. + if (type == FileSystemEvent.modify) { + type = event.type; + continue; + } + + // A CREATE event contradicts a REMOVE event and vice versa. + assert(type == FileSystemEvent.create || + type == FileSystemEvent.delete || + type == FileSystemEvent.move); + if (type != event.type) return null; + } + + switch (type) { + case FileSystemEvent.create: + return FileSystemCreateEvent(batch.first.path, isDir); + case FileSystemEvent.delete: + return FileSystemDeleteEvent(batch.first.path, isDir); + case FileSystemEvent.modify: + return FileSystemModifyEvent(batch.first.path, isDir, false); + case FileSystemEvent.move: + return null; + default: + throw StateError('unreachable'); + } + } + + /// Returns zero or more events that describe the change between the last + /// known state of [path] and its current state on the filesystem. + /// + /// This returns a list whose order should be reflected in the events emitted + /// to the user, unlike the batched events from [Directory.watch]. The + /// returned list may be empty, indicating that no changes occurred to [path] + /// (probably indicating that it was created and then immediately deleted). + List _eventsBasedOnFileSystem(String path) { + var fileExisted = _files.contains(path); + var dirExisted = _files.containsDir(path); + + bool fileExists; + bool dirExists; + try { + fileExists = File(path).existsSync(); + dirExists = Directory(path).existsSync(); + } on FileSystemException { + return const []; + } + + var events = []; + if (fileExisted) { + if (fileExists) { + events.add(FileSystemModifyEvent(path, false, false)); + } else { + events.add(FileSystemDeleteEvent(path, false)); + } + } else if (dirExisted) { + if (dirExists) { + // If we got contradictory events for a directory that used to exist and + // still exists, we need to rescan the whole thing in case it was + // replaced with a different directory. + events.add(FileSystemDeleteEvent(path, true)); + events.add(FileSystemCreateEvent(path, true)); + } else { + events.add(FileSystemDeleteEvent(path, true)); + } + } + + if (!fileExisted && fileExists) { + events.add(FileSystemCreateEvent(path, false)); + } else if (!dirExisted && dirExists) { + events.add(FileSystemCreateEvent(path, true)); + } + + return events; + } + + /// The callback that's run when the [Directory.watch] stream is closed. + /// Note that this is unlikely to happen on Windows, unless the system itself + /// closes the handle. + void _onDone() { + _watchSubscription = null; + + // Emit remove events for any remaining files. + for (var file in _files.paths) { + _emitEvent(ChangeType.REMOVE, file); + } + _files.clear(); + close(); + } + + /// Start or restart the underlying [Directory.watch] stream. + void _startWatch() { + // Note: "watcher closed" exceptions do not get sent over the stream + // returned by watch, and must be caught via a zone handler. + runZonedGuarded(() { + var innerStream = Directory(path).watch(recursive: true); + _watchSubscription = innerStream.listen(_onEvent, + onError: _eventsController.addError, onDone: _onDone); + }, (error, stackTrace) { + if (error is FileSystemException && + error.message.startsWith('Directory watcher closed unexpectedly')) { + _watchSubscription?.cancel(); + _eventsController.addError(error, stackTrace); + _startWatch(); + } else { + // ignore: only_throw_errors + throw error; + } + }); + } + + /// Starts or restarts listing the watched directory to get an initial picture + /// of its state. + Future _listDir() { + assert(!isReady); + _initialListSubscription?.cancel(); + + _files.clear(); + var completer = Completer(); + var stream = Directory(path).list(recursive: true); + void handleEntity(FileSystemEntity entity) { + if (entity is! Directory) _files.add(entity.path); + } + + _initialListSubscription = stream.listen(handleEntity, + onError: _emitError, onDone: completer.complete, cancelOnError: true); + return completer.future; + } + + /// Emit an event with the given [type] and [path]. + void _emitEvent(ChangeType type, String path) { + if (!isReady) return; + + _eventsController.add(WatchEvent(type, path)); + } + + /// Emit an error, then close the watcher. + void _emitError(Object error, StackTrace stackTrace) { + // Guarantee that ready always completes. + if (!isReady) { + _readyCompleter.complete(); + } + _eventsController.addError(error, stackTrace); + close(); + } +} diff --git a/pkgs/watcher/lib/src/file_watcher.dart b/pkgs/watcher/lib/src/file_watcher.dart new file mode 100644 index 000000000..143aa3172 --- /dev/null +++ b/pkgs/watcher/lib/src/file_watcher.dart @@ -0,0 +1,44 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:io'; + +import '../watcher.dart'; +import 'custom_watcher_factory.dart'; +import 'file_watcher/native.dart'; + +/// Watches a file and emits [WatchEvent]s when the file has changed. +/// +/// Note that since each watcher only watches a single file, it will only emit +/// [ChangeType.MODIFY] events, except when the file is deleted at which point +/// it will emit a single [ChangeType.REMOVE] event and then close the stream. +/// +/// If the file is deleted and quickly replaced (when a new file is moved in its +/// place, for example) this will emit a [ChangeType.MODIFY] event. +abstract class FileWatcher implements Watcher { + /// Creates a new [FileWatcher] monitoring [file]. + /// + /// If a native file watcher is available for this platform, this will use it. + /// Otherwise, it will fall back to a [PollingFileWatcher]. Notably, native + /// file watching is *not* supported on Windows. + /// + /// If [pollingDelay] is passed, it specifies the amount of time the watcher + /// will pause between successive polls of the directory contents. Making this + /// shorter will give more immediate feedback at the expense of doing more IO + /// and higher CPU usage. Defaults to one second. Ignored for non-polling + /// watchers. + factory FileWatcher(String file, {Duration? pollingDelay}) { + var customWatcher = + createCustomFileWatcher(file, pollingDelay: pollingDelay); + if (customWatcher != null) return customWatcher; + + // [File.watch] doesn't work on Windows, but + // [FileSystemEntity.isWatchSupported] is still true because directory + // watching does work. + if (FileSystemEntity.isWatchSupported && !Platform.isWindows) { + return NativeFileWatcher(file); + } + return PollingFileWatcher(file, pollingDelay: pollingDelay); + } +} diff --git a/pkgs/watcher/lib/src/file_watcher/native.dart b/pkgs/watcher/lib/src/file_watcher/native.dart new file mode 100644 index 000000000..502aa1095 --- /dev/null +++ b/pkgs/watcher/lib/src/file_watcher/native.dart @@ -0,0 +1,90 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import '../file_watcher.dart'; +import '../resubscribable.dart'; +import '../utils.dart'; +import '../watch_event.dart'; + +/// Uses the native file system notifications to watch for filesystem events. +/// +/// Single-file notifications are much simpler than those for multiple files, so +/// this doesn't need to be split out into multiple OS-specific classes. +class NativeFileWatcher extends ResubscribableWatcher implements FileWatcher { + NativeFileWatcher(String path) : super(path, () => _NativeFileWatcher(path)); +} + +class _NativeFileWatcher implements FileWatcher, ManuallyClosedWatcher { + @override + final String path; + + @override + Stream get events => _eventsController.stream; + final _eventsController = StreamController.broadcast(); + + @override + bool get isReady => _readyCompleter.isCompleted; + + @override + Future get ready => _readyCompleter.future; + final _readyCompleter = Completer(); + + StreamSubscription>? _subscription; + + _NativeFileWatcher(this.path) { + _listen(); + + // We don't need to do any initial set-up, so we're ready immediately after + // being listened to. + _readyCompleter.complete(); + } + + void _listen() { + // Batch the events together so that we can dedup them. + _subscription = File(path) + .watch() + .batchEvents() + .listen(_onBatch, onError: _eventsController.addError, onDone: _onDone); + } + + void _onBatch(List batch) { + if (batch.any((event) => event.type == FileSystemEvent.delete)) { + // If the file is deleted, the underlying stream will close. We handle + // emitting our own REMOVE event in [_onDone]. + return; + } + + _eventsController.add(WatchEvent(ChangeType.MODIFY, path)); + } + + void _onDone() async { + var fileExists = await File(path).exists(); + + // Check for this after checking whether the file exists because it's + // possible that [close] was called between [File.exists] being called and + // it completing. + if (_eventsController.isClosed) return; + + if (fileExists) { + // If the file exists now, it was probably removed and quickly replaced; + // this can happen for example when another file is moved on top of it. + // Re-subscribe and report a modify event. + _eventsController.add(WatchEvent(ChangeType.MODIFY, path)); + _listen(); + } else { + _eventsController.add(WatchEvent(ChangeType.REMOVE, path)); + close(); + } + } + + @override + void close() { + _subscription?.cancel(); + _subscription = null; + _eventsController.close(); + } +} diff --git a/pkgs/watcher/lib/src/file_watcher/polling.dart b/pkgs/watcher/lib/src/file_watcher/polling.dart new file mode 100644 index 000000000..15ff9ab8e --- /dev/null +++ b/pkgs/watcher/lib/src/file_watcher/polling.dart @@ -0,0 +1,106 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import '../file_watcher.dart'; +import '../resubscribable.dart'; +import '../stat.dart'; +import '../watch_event.dart'; + +/// Periodically polls a file for changes. +class PollingFileWatcher extends ResubscribableWatcher implements FileWatcher { + PollingFileWatcher(String path, {Duration? pollingDelay}) + : super(path, () { + return _PollingFileWatcher( + path, pollingDelay ?? const Duration(seconds: 1)); + }); +} + +class _PollingFileWatcher implements FileWatcher, ManuallyClosedWatcher { + @override + final String path; + + @override + Stream get events => _eventsController.stream; + final _eventsController = StreamController.broadcast(); + + @override + bool get isReady => _readyCompleter.isCompleted; + + @override + Future get ready => _readyCompleter.future; + final _readyCompleter = Completer(); + + /// The timer that controls polling. + late final Timer _timer; + + /// The previous modification time of the file. + /// + /// `null` indicates the file does not (or did not on the last poll) exist. + DateTime? _lastModified; + + _PollingFileWatcher(this.path, Duration pollingDelay) { + _timer = Timer.periodic(pollingDelay, (_) => _poll()); + _poll(); + } + + /// Checks the mtime of the file and whether it's been removed. + Future _poll() async { + // We don't mark the file as removed if this is the first poll. Instead, + // below we forward the dart:io error that comes from trying to read the + // mtime below. + var pathExists = await File(path).exists(); + if (_eventsController.isClosed) return; + + if (_lastModified != null && !pathExists) { + _flagReady(); + _eventsController.add(WatchEvent(ChangeType.REMOVE, path)); + unawaited(close()); + return; + } + + DateTime? modified; + try { + modified = await modificationTime(path); + } on FileSystemException catch (error, stackTrace) { + if (!_eventsController.isClosed) { + _flagReady(); + _eventsController.addError(error, stackTrace); + await close(); + } + } + if (_eventsController.isClosed) { + _flagReady(); + return; + } + + if (!isReady) { + // If this is the first poll, don't emit an event, just set the last mtime + // and complete the completer. + _lastModified = modified; + _flagReady(); + return; + } + + if (_lastModified == modified) return; + + _lastModified = modified; + _eventsController.add(WatchEvent(ChangeType.MODIFY, path)); + } + + /// Flags this watcher as ready if it has not already been done. + void _flagReady() { + if (!isReady) { + _readyCompleter.complete(); + } + } + + @override + Future close() async { + _timer.cancel(); + await _eventsController.close(); + } +} diff --git a/pkgs/watcher/lib/src/path_set.dart b/pkgs/watcher/lib/src/path_set.dart new file mode 100644 index 000000000..4f41cf924 --- /dev/null +++ b/pkgs/watcher/lib/src/path_set.dart @@ -0,0 +1,190 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; + +import 'package:path/path.dart' as p; + +/// A set of paths, organized into a directory hierarchy. +/// +/// When a path is [add]ed, it creates an implicit directory structure above +/// that path. Directories can be inspected using [containsDir] and removed +/// using [remove]. If they're removed, their contents are removed as well. +/// +/// The paths in the set are normalized so that they all begin with [root]. +class PathSet { + /// The root path, which all paths in the set must be under. + final String root; + + /// The path set's directory hierarchy. + /// + /// Each entry represents a directory or file. It may be a file or directory + /// that was explicitly added, or a parent directory that was implicitly + /// added in order to add a child. + final _Entry _entries = _Entry(); + + PathSet(this.root); + + /// Adds [path] to the set. + void add(String path) { + path = _normalize(path); + + var parts = p.split(path); + var entry = _entries; + for (var part in parts) { + entry = entry.contents.putIfAbsent(part, _Entry.new); + } + + entry.isExplicit = true; + } + + /// Removes [path] and any paths beneath it from the set and returns the + /// removed paths. + /// + /// Even if [path] itself isn't in the set, if it's a directory containing + /// paths that are in the set those paths will be removed and returned. + /// + /// If neither [path] nor any paths beneath it are in the set, returns an + /// empty set. + Set remove(String path) { + path = _normalize(path); + var parts = Queue.of(p.split(path)); + + // Remove the children of [dir], as well as [dir] itself if necessary. + // + // [partialPath] is the path to [dir], and a prefix of [path]; the remaining + // components of [path] are in [parts]. + Set recurse(_Entry dir, String partialPath) { + if (parts.length > 1) { + // If there's more than one component left in [path], recurse down to + // the next level. + var part = parts.removeFirst(); + var entry = dir.contents[part]; + if (entry == null || entry.contents.isEmpty) return {}; + + partialPath = p.join(partialPath, part); + var paths = recurse(entry, partialPath); + // After removing this entry's children, if it has no more children and + // it's not in the set in its own right, remove it as well. + if (entry.contents.isEmpty && !entry.isExplicit) { + dir.contents.remove(part); + } + return paths; + } + + // If there's only one component left in [path], we should remove it. + var entry = dir.contents.remove(parts.first); + if (entry == null) return {}; + + if (entry.contents.isEmpty) { + return {p.join(root, path)}; + } + + var set = _explicitPathsWithin(entry, path); + if (entry.isExplicit) { + set.add(p.join(root, path)); + } + + return set; + } + + return recurse(_entries, root); + } + + /// Recursively lists all of the explicit paths within [dir]. + /// + /// [dirPath] should be the path to [dir]. + Set _explicitPathsWithin(_Entry dir, String dirPath) { + var paths = {}; + void recurse(_Entry dir, String path) { + dir.contents.forEach((name, entry) { + var entryPath = p.join(path, name); + if (entry.isExplicit) paths.add(p.join(root, entryPath)); + + recurse(entry, entryPath); + }); + } + + recurse(dir, dirPath); + return paths; + } + + /// Returns whether this set contains [path]. + /// + /// This only returns true for paths explicitly added to this set. + /// Implicitly-added directories can be inspected using [containsDir]. + bool contains(String path) { + path = _normalize(path); + var entry = _entries; + + for (var part in p.split(path)) { + var child = entry.contents[part]; + if (child == null) return false; + entry = child; + } + + return entry.isExplicit; + } + + /// Returns whether this set contains paths beneath [path]. + bool containsDir(String path) { + path = _normalize(path); + var entry = _entries; + + for (var part in p.split(path)) { + var child = entry.contents[part]; + if (child == null) return false; + entry = child; + } + + return entry.contents.isNotEmpty; + } + + /// All of the paths explicitly added to this set. + List get paths { + var result = []; + + void recurse(_Entry dir, String path) { + for (var mapEntry in dir.contents.entries) { + var entry = mapEntry.value; + var entryPath = p.join(path, mapEntry.key); + if (entry.isExplicit) result.add(entryPath); + recurse(entry, entryPath); + } + } + + recurse(_entries, root); + return result; + } + + /// Removes all paths from this set. + void clear() { + _entries.contents.clear(); + } + + /// Returns a normalized version of [path]. + /// + /// This removes any extra ".." or "."s and ensure that the returned path + /// begins with [root]. It's an error if [path] isn't within [root]. + String _normalize(String path) { + assert(p.isWithin(root, path)); + + return p.relative(p.normalize(path), from: root); + } +} + +/// A virtual file system entity tracked by the [PathSet]. +/// +/// It may have child entries in [contents], which implies it's a directory. +class _Entry { + /// The child entries contained in this directory. + final Map contents = {}; + + /// If this entry was explicitly added as a leaf file system entity, this + /// will be true. + /// + /// Otherwise, it represents a parent directory that was implicitly added + /// when added some child of it. + bool isExplicit = false; +} diff --git a/pkgs/watcher/lib/src/resubscribable.dart b/pkgs/watcher/lib/src/resubscribable.dart new file mode 100644 index 000000000..b99e9d7b4 --- /dev/null +++ b/pkgs/watcher/lib/src/resubscribable.dart @@ -0,0 +1,79 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import '../watcher.dart'; + +/// A wrapper for [ManuallyClosedWatcher] that encapsulates support for closing +/// the watcher when it has no subscribers and re-opening it when it's +/// re-subscribed. +/// +/// It's simpler to implement watchers without worrying about this behavior. +/// This class wraps a watcher class which can be written with the simplifying +/// assumption that it can continue emitting events until an explicit `close` +/// method is called, at which point it will cease emitting events entirely. The +/// [ManuallyClosedWatcher] interface is used for these watchers. +/// +/// This would be more cleanly implemented as a function that takes a class and +/// emits a new class, but Dart doesn't support that sort of thing. Instead it +/// takes a factory function that produces instances of the inner class. +abstract class ResubscribableWatcher implements Watcher { + /// The factory function that produces instances of the inner class. + final ManuallyClosedWatcher Function() _factory; + + @override + final String path; + + @override + Stream get events => _eventsController.stream; + late StreamController _eventsController; + + @override + bool get isReady => _readyCompleter.isCompleted; + + @override + Future get ready => _readyCompleter.future; + var _readyCompleter = Completer(); + + /// Creates a new [ResubscribableWatcher] wrapping the watchers + /// emitted by [_factory]. + ResubscribableWatcher(this.path, this._factory) { + late ManuallyClosedWatcher watcher; + late StreamSubscription subscription; + + _eventsController = StreamController.broadcast( + onListen: () async { + watcher = _factory(); + subscription = watcher.events.listen(_eventsController.add, + onError: _eventsController.addError, + onDone: _eventsController.close); + + // It's important that we complete the value of [_readyCompleter] at + // the time [onListen] is called, as opposed to the value when + // [watcher.ready] fires. A new completer may be created by that time. + await watcher.ready; + _readyCompleter.complete(); + }, + onCancel: () { + // Cancel the subscription before closing the watcher so that the + // watcher's `onDone` event doesn't close [events]. + subscription.cancel(); + watcher.close(); + _readyCompleter = Completer(); + }, + sync: true); + } +} + +/// An interface for watchers with an explicit, manual [close] method. +/// +/// See [ResubscribableWatcher]. +abstract class ManuallyClosedWatcher implements Watcher { + /// Closes the watcher. + /// + /// Subclasses should close their [events] stream and release any internal + /// resources. + void close(); +} diff --git a/pkgs/watcher/lib/src/stat.dart b/pkgs/watcher/lib/src/stat.dart new file mode 100644 index 000000000..fe0f15578 --- /dev/null +++ b/pkgs/watcher/lib/src/stat.dart @@ -0,0 +1,34 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:io'; + +/// A function that takes a file path and returns the last modified time for +/// the file at that path. +typedef MockTimeCallback = DateTime? Function(String path); + +MockTimeCallback? _mockTimeCallback; + +/// Overrides the default behavior for accessing a file's modification time +/// with [callback]. +/// +/// The OS file modification time has pretty rough granularity (like a few +/// seconds) which can make for slow tests that rely on modtime. This lets you +/// replace it with something you control. +void mockGetModificationTime(MockTimeCallback callback) { + _mockTimeCallback = callback; +} + +/// Gets the modification time for the file at [path]. +/// Completes with `null` if the file does not exist. +Future modificationTime(String path) async { + var mockTimeCallback = _mockTimeCallback; + if (mockTimeCallback != null) { + return mockTimeCallback(path); + } + + final stat = await FileStat.stat(path); + if (stat.type == FileSystemEntityType.notFound) return null; + return stat.modified; +} diff --git a/pkgs/watcher/lib/src/utils.dart b/pkgs/watcher/lib/src/utils.dart new file mode 100644 index 000000000..c2e71b3c1 --- /dev/null +++ b/pkgs/watcher/lib/src/utils.dart @@ -0,0 +1,52 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; + +/// Returns `true` if [error] is a [FileSystemException] for a missing +/// directory. +bool isDirectoryNotFoundException(Object error) { + if (error is! FileSystemException) return false; + + // See dartbug.com/12461 and tests/standalone/io/directory_error_test.dart. + var notFoundCode = Platform.operatingSystem == 'windows' ? 3 : 2; + return error.osError?.errorCode == notFoundCode; +} + +/// Returns the union of all elements in each set in [sets]. +Set unionAll(Iterable> sets) => + sets.fold({}, (union, set) => union.union(set)); + +extension BatchEvents on Stream { + /// Batches all events that are sent at the same time. + /// + /// When multiple events are synchronously added to a stream controller, the + /// [StreamController] implementation uses [scheduleMicrotask] to schedule the + /// asynchronous firing of each event. In order to recreate the synchronous + /// batches, this collates all the events that are received in "nearby" + /// microtasks. + Stream> batchEvents() { + var batch = Queue(); + return StreamTransformer>.fromHandlers( + handleData: (event, sink) { + batch.add(event); + + // [Timer.run] schedules an event that runs after any microtasks that have + // been scheduled. + Timer.run(() { + if (batch.isEmpty) return; + sink.add(batch.toList()); + batch.clear(); + }); + }, handleDone: (sink) { + if (batch.isNotEmpty) { + sink.add(batch.toList()); + batch.clear(); + } + sink.close(); + }).bind(this); + } +} diff --git a/pkgs/watcher/lib/src/watch_event.dart b/pkgs/watcher/lib/src/watch_event.dart new file mode 100644 index 000000000..b65afc2bd --- /dev/null +++ b/pkgs/watcher/lib/src/watch_event.dart @@ -0,0 +1,38 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// An event describing a single change to the file system. +class WatchEvent { + /// The manner in which the file at [path] has changed. + final ChangeType type; + + /// The path of the file that changed. + final String path; + + WatchEvent(this.type, this.path); + + @override + String toString() => '$type $path'; +} + +/// Enum for what kind of change has happened to a file. +class ChangeType { + /// A new file has been added. + // ignore: constant_identifier_names + static const ADD = ChangeType('add'); + + /// A file has been removed. + // ignore: constant_identifier_names + static const REMOVE = ChangeType('remove'); + + /// The contents of a file have changed. + // ignore: constant_identifier_names + static const MODIFY = ChangeType('modify'); + + final String _name; + const ChangeType(this._name); + + @override + String toString() => _name; +} diff --git a/pkgs/watcher/lib/watcher.dart b/pkgs/watcher/lib/watcher.dart new file mode 100644 index 000000000..12a536961 --- /dev/null +++ b/pkgs/watcher/lib/watcher.dart @@ -0,0 +1,70 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:io'; + +import 'src/directory_watcher.dart'; +import 'src/file_watcher.dart'; +import 'src/watch_event.dart'; + +export 'src/custom_watcher_factory.dart' show registerCustomWatcher; +export 'src/directory_watcher.dart'; +export 'src/directory_watcher/polling.dart'; +export 'src/file_watcher.dart'; +export 'src/file_watcher/polling.dart'; +export 'src/watch_event.dart'; + +abstract class Watcher { + /// The path to the file or directory whose contents are being monitored. + String get path; + + /// The broadcast [Stream] of events that have occurred to the watched file or + /// files in the watched directory. + /// + /// Changes will only be monitored while this stream has subscribers. Any + /// changes that occur during periods when there are no subscribers will not + /// be reported the next time a subscriber is added. + Stream get events; + + /// Whether the watcher is initialized and watching for changes. + /// + /// This is true if and only if [ready] is complete. + bool get isReady; + + /// A [Future] that completes when the watcher is initialized and watching for + /// changes. + /// + /// If the watcher is not currently monitoring the file or directory (because + /// there are no subscribers to [events]), this returns a future that isn't + /// complete yet. It will complete when a subscriber starts listening and the + /// watcher finishes any initialization work it needs to do. + /// + /// If the watcher is already monitoring, this returns an already complete + /// future. + /// + /// This future always completes successfully as errors are provided through + /// the [events] stream. + Future get ready; + + /// Creates a new [DirectoryWatcher] or [FileWatcher] monitoring [path], + /// depending on whether it's a file or directory. + /// + /// If a native watcher is available for this platform, this will use it. + /// Otherwise, it will fall back to a polling watcher. Notably, watching + /// individual files is not natively supported on Windows, although watching + /// directories is. + /// + /// If [pollingDelay] is passed, it specifies the amount of time the watcher + /// will pause between successive polls of the contents of [path]. Making this + /// shorter will give more immediate feedback at the expense of doing more IO + /// and higher CPU usage. Defaults to one second. Ignored for non-polling + /// watchers. + factory Watcher(String path, {Duration? pollingDelay}) { + if (File(path).existsSync()) { + return FileWatcher(path, pollingDelay: pollingDelay); + } else { + return DirectoryWatcher(path, pollingDelay: pollingDelay); + } + } +} diff --git a/pkgs/watcher/pubspec.yaml b/pkgs/watcher/pubspec.yaml new file mode 100644 index 000000000..7781bd445 --- /dev/null +++ b/pkgs/watcher/pubspec.yaml @@ -0,0 +1,19 @@ +name: watcher +version: 1.1.1 +description: >- + A file system watcher. It monitors changes to contents of directories and + sends notifications when files have been added, removed, or modified. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/watcher + +environment: + sdk: ^3.1.0 + +dependencies: + async: ^2.5.0 + path: ^1.8.0 + +dev_dependencies: + benchmark_harness: ^2.0.0 + dart_flutter_team_lints: ^3.0.0 + test: ^1.16.6 + test_descriptor: ^2.0.0 diff --git a/pkgs/watcher/test/custom_watcher_factory_test.dart b/pkgs/watcher/test/custom_watcher_factory_test.dart new file mode 100644 index 000000000..e9d65bb55 --- /dev/null +++ b/pkgs/watcher/test/custom_watcher_factory_test.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:test/test.dart'; +import 'package:watcher/watcher.dart'; + +void main() { + late _MemFs memFs; + final memFsFactoryId = 'MemFs'; + final noOpFactoryId = 'NoOp'; + + setUpAll(() { + memFs = _MemFs(); + var memFsWatcherFactory = _MemFsWatcherFactory(memFs); + var noOpWatcherFactory = _NoOpWatcherFactory(); + registerCustomWatcher( + noOpFactoryId, + noOpWatcherFactory.createDirectoryWatcher, + noOpWatcherFactory.createFileWatcher); + registerCustomWatcher( + memFsFactoryId, + memFsWatcherFactory.createDirectoryWatcher, + memFsWatcherFactory.createFileWatcher); + }); + + test('notifies for files', () async { + var watcher = FileWatcher('file.txt'); + + var completer = Completer(); + watcher.events.listen((event) => completer.complete(event)); + await watcher.ready; + memFs.add('file.txt'); + var event = await completer.future; + + expect(event.type, ChangeType.ADD); + expect(event.path, 'file.txt'); + }); + + test('notifies for directories', () async { + var watcher = DirectoryWatcher('dir'); + + var completer = Completer(); + watcher.events.listen((event) => completer.complete(event)); + await watcher.ready; + memFs.add('dir'); + var event = await completer.future; + + expect(event.type, ChangeType.ADD); + expect(event.path, 'dir'); + }); + + test('registering twice throws', () async { + expect( + () => registerCustomWatcher( + memFsFactoryId, + (_, {pollingDelay}) => throw UnimplementedError(), + (_, {pollingDelay}) => throw UnimplementedError()), + throwsA(isA()), + ); + }); + + test('finding two applicable factories throws', () async { + // Note that _MemFsWatcherFactory always returns a watcher, so having two + // will always produce a conflict. + var watcherFactory = _MemFsWatcherFactory(memFs); + registerCustomWatcher('Different id', watcherFactory.createDirectoryWatcher, + watcherFactory.createFileWatcher); + expect(() => FileWatcher('file.txt'), throwsA(isA())); + expect(() => DirectoryWatcher('dir'), throwsA(isA())); + }); +} + +class _MemFs { + final _streams = >>{}; + + StreamController watchStream(String path) { + var controller = StreamController(); + _streams + .putIfAbsent(path, () => >{}) + .add(controller); + return controller; + } + + void add(String path) { + var controllers = _streams[path]; + if (controllers != null) { + for (var controller in controllers) { + controller.add(WatchEvent(ChangeType.ADD, path)); + } + } + } + + void remove(String path) { + var controllers = _streams[path]; + if (controllers != null) { + for (var controller in controllers) { + controller.add(WatchEvent(ChangeType.REMOVE, path)); + } + } + } +} + +class _MemFsWatcher implements FileWatcher, DirectoryWatcher, Watcher { + final String _path; + final StreamController _controller; + + _MemFsWatcher(this._path, this._controller); + + @override + String get path => _path; + + @override + String get directory => throw UnsupportedError('directory is not supported'); + + @override + Stream get events => _controller.stream; + + @override + bool get isReady => true; + + @override + Future get ready async {} +} + +class _MemFsWatcherFactory { + final _MemFs _memFs; + _MemFsWatcherFactory(this._memFs); + + DirectoryWatcher? createDirectoryWatcher(String path, + {Duration? pollingDelay}) => + _MemFsWatcher(path, _memFs.watchStream(path)); + + FileWatcher? createFileWatcher(String path, {Duration? pollingDelay}) => + _MemFsWatcher(path, _memFs.watchStream(path)); +} + +class _NoOpWatcherFactory { + DirectoryWatcher? createDirectoryWatcher(String path, + {Duration? pollingDelay}) => + null; + + FileWatcher? createFileWatcher(String path, {Duration? pollingDelay}) => null; +} diff --git a/pkgs/watcher/test/directory_watcher/linux_test.dart b/pkgs/watcher/test/directory_watcher/linux_test.dart new file mode 100644 index 000000000..a10a72c33 --- /dev/null +++ b/pkgs/watcher/test/directory_watcher/linux_test.dart @@ -0,0 +1,44 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('linux') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/linux.dart'; +import 'package:watcher/watcher.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = LinuxDirectoryWatcher.new; + + sharedTests(); + + test('DirectoryWatcher creates a LinuxDirectoryWatcher on Linux', () { + expect(DirectoryWatcher('.'), const TypeMatcher()); + }); + + test('emits events for many nested files moved out then immediately back in', + () async { + withPermutations( + (i, j, k) => writeFile('dir/sub/sub-$i/sub-$j/file-$k.txt')); + await startWatcher(path: 'dir'); + + renameDir('dir/sub', 'sub'); + renameDir('sub', 'dir/sub'); + + await allowEither(() { + inAnyOrder(withPermutations( + (i, j, k) => isRemoveEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + + inAnyOrder(withPermutations( + (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + }, () { + inAnyOrder(withPermutations( + (i, j, k) => isModifyEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + }); + }); +} diff --git a/pkgs/watcher/test/directory_watcher/mac_os_test.dart b/pkgs/watcher/test/directory_watcher/mac_os_test.dart new file mode 100644 index 000000000..337662646 --- /dev/null +++ b/pkgs/watcher/test/directory_watcher/mac_os_test.dart @@ -0,0 +1,69 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('mac-os') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/mac_os.dart'; +import 'package:watcher/watcher.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = MacOSDirectoryWatcher.new; + + sharedTests(); + + test('DirectoryWatcher creates a MacOSDirectoryWatcher on Mac OS', () { + expect(DirectoryWatcher('.'), const TypeMatcher()); + }); + + test( + 'does not notify about the watched directory being deleted and ' + 'recreated immediately before watching', () async { + createDir('dir'); + writeFile('dir/old.txt'); + deleteDir('dir'); + createDir('dir'); + + await startWatcher(path: 'dir'); + writeFile('dir/newer.txt'); + await expectAddEvent('dir/newer.txt'); + }); + + test('emits events for many nested files moved out then immediately back in', + () async { + withPermutations( + (i, j, k) => writeFile('dir/sub/sub-$i/sub-$j/file-$k.txt')); + + await startWatcher(path: 'dir'); + + renameDir('dir/sub', 'sub'); + renameDir('sub', 'dir/sub'); + + await allowEither(() { + inAnyOrder(withPermutations( + (i, j, k) => isRemoveEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + + inAnyOrder(withPermutations( + (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + }, () { + inAnyOrder(withPermutations( + (i, j, k) => isModifyEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + }); + }); + test('does not suppress files with the same prefix as a directory', () async { + // Regression test for https://github.com/dart-lang/watcher/issues/83 + writeFile('some_name.txt'); + + await startWatcher(); + + writeFile('some_name/some_name.txt'); + deleteFile('some_name.txt'); + + await expectRemoveEvent('some_name.txt'); + }); +} diff --git a/pkgs/watcher/test/directory_watcher/polling_test.dart b/pkgs/watcher/test/directory_watcher/polling_test.dart new file mode 100644 index 000000000..f4ec8f48a --- /dev/null +++ b/pkgs/watcher/test/directory_watcher/polling_test.dart @@ -0,0 +1,26 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:watcher/watcher.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + // Use a short delay to make the tests run quickly. + watcherFactory = (dir) => PollingDirectoryWatcher(dir, + pollingDelay: const Duration(milliseconds: 100)); + + sharedTests(); + + test('does not notify if the modification time did not change', () async { + writeFile('a.txt', contents: 'before'); + writeFile('b.txt', contents: 'before'); + await startWatcher(); + writeFile('a.txt', contents: 'after', updateModified: false); + writeFile('b.txt', contents: 'after'); + await expectModifyEvent('b.txt'); + }); +} diff --git a/pkgs/watcher/test/directory_watcher/shared.dart b/pkgs/watcher/test/directory_watcher/shared.dart new file mode 100644 index 000000000..1ebc78d4b --- /dev/null +++ b/pkgs/watcher/test/directory_watcher/shared.dart @@ -0,0 +1,344 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:watcher/src/utils.dart'; + +import '../utils.dart'; + +void sharedTests() { + test('does not notify for files that already exist when started', () async { + // Make some pre-existing files. + writeFile('a.txt'); + writeFile('b.txt'); + + await startWatcher(); + + // Change one after the watcher is running. + writeFile('b.txt', contents: 'modified'); + + // We should get a modify event for the changed file, but no add events + // for them before this. + await expectModifyEvent('b.txt'); + }); + + test('notifies when a file is added', () async { + await startWatcher(); + writeFile('file.txt'); + await expectAddEvent('file.txt'); + }); + + test('notifies when a file is modified', () async { + writeFile('file.txt'); + await startWatcher(); + writeFile('file.txt', contents: 'modified'); + await expectModifyEvent('file.txt'); + }); + + test('notifies when a file is removed', () async { + writeFile('file.txt'); + await startWatcher(); + deleteFile('file.txt'); + await expectRemoveEvent('file.txt'); + }); + + test('notifies when a file is modified multiple times', () async { + writeFile('file.txt'); + await startWatcher(); + writeFile('file.txt', contents: 'modified'); + await expectModifyEvent('file.txt'); + writeFile('file.txt', contents: 'modified again'); + await expectModifyEvent('file.txt'); + }); + + test('notifies even if the file contents are unchanged', () async { + writeFile('a.txt', contents: 'same'); + writeFile('b.txt', contents: 'before'); + await startWatcher(); + + writeFile('a.txt', contents: 'same'); + writeFile('b.txt', contents: 'after'); + await inAnyOrder([isModifyEvent('a.txt'), isModifyEvent('b.txt')]); + }); + + test('when the watched directory is deleted, removes all files', () async { + writeFile('dir/a.txt'); + writeFile('dir/b.txt'); + + await startWatcher(path: 'dir'); + + deleteDir('dir'); + await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]); + }); + + test('when the watched directory is moved, removes all files', () async { + writeFile('dir/a.txt'); + writeFile('dir/b.txt'); + + await startWatcher(path: 'dir'); + + renameDir('dir', 'moved_dir'); + createDir('dir'); + await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]); + }); + + // Regression test for b/30768513. + test( + "doesn't crash when the directory is moved immediately after a subdir " + 'is added', () async { + writeFile('dir/a.txt'); + writeFile('dir/b.txt'); + + await startWatcher(path: 'dir'); + + createDir('dir/subdir'); + renameDir('dir', 'moved_dir'); + createDir('dir'); + await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]); + }); + + group('moves', () { + test('notifies when a file is moved within the watched directory', + () async { + writeFile('old.txt'); + await startWatcher(); + renameFile('old.txt', 'new.txt'); + + await inAnyOrder([isAddEvent('new.txt'), isRemoveEvent('old.txt')]); + }); + + test('notifies when a file is moved from outside the watched directory', + () async { + writeFile('old.txt'); + createDir('dir'); + await startWatcher(path: 'dir'); + + renameFile('old.txt', 'dir/new.txt'); + await expectAddEvent('dir/new.txt'); + }); + + test('notifies when a file is moved outside the watched directory', + () async { + writeFile('dir/old.txt'); + await startWatcher(path: 'dir'); + + renameFile('dir/old.txt', 'new.txt'); + await expectRemoveEvent('dir/old.txt'); + }); + + test('notifies when a file is moved onto an existing one', () async { + writeFile('from.txt'); + writeFile('to.txt'); + await startWatcher(); + + renameFile('from.txt', 'to.txt'); + await inAnyOrder([isRemoveEvent('from.txt'), isModifyEvent('to.txt')]); + }, onPlatform: { + 'windows': const Skip('https://github.com/dart-lang/watcher/issues/125') + }); + }); + + // Most of the time, when multiple filesystem actions happen in sequence, + // they'll be batched together and the watcher will see them all at once. + // These tests verify that the watcher normalizes and combine these events + // properly. However, very occasionally the events will be reported in + // separate batches, and the watcher will report them as though they occurred + // far apart in time, so each of these tests has a "backup case" to allow for + // that as well. + group('clustered changes', () { + test("doesn't notify when a file is created and then immediately removed", + () async { + writeFile('test.txt'); + await startWatcher(); + writeFile('file.txt'); + deleteFile('file.txt'); + + // Backup case. + startClosingEventStream(); + await allowEvents(() { + expectAddEvent('file.txt'); + expectRemoveEvent('file.txt'); + }); + }); + + test( + 'reports a modification when a file is deleted and then immediately ' + 'recreated', () async { + writeFile('file.txt'); + await startWatcher(); + + deleteFile('file.txt'); + writeFile('file.txt', contents: 're-created'); + + await allowEither(() { + expectModifyEvent('file.txt'); + }, () { + // Backup case. + expectRemoveEvent('file.txt'); + expectAddEvent('file.txt'); + }); + }); + + test( + 'reports a modification when a file is moved and then immediately ' + 'recreated', () async { + writeFile('old.txt'); + await startWatcher(); + + renameFile('old.txt', 'new.txt'); + writeFile('old.txt', contents: 're-created'); + + await allowEither(() { + inAnyOrder([isModifyEvent('old.txt'), isAddEvent('new.txt')]); + }, () { + // Backup case. + expectRemoveEvent('old.txt'); + expectAddEvent('new.txt'); + expectAddEvent('old.txt'); + }); + }); + + test( + 'reports a removal when a file is modified and then immediately ' + 'removed', () async { + writeFile('file.txt'); + await startWatcher(); + + writeFile('file.txt', contents: 'modified'); + deleteFile('file.txt'); + + // Backup case. + await allowModifyEvent('file.txt'); + + await expectRemoveEvent('file.txt'); + }); + + test('reports an add when a file is added and then immediately modified', + () async { + await startWatcher(); + + writeFile('file.txt'); + writeFile('file.txt', contents: 'modified'); + + await expectAddEvent('file.txt'); + + // Backup case. + startClosingEventStream(); + await allowModifyEvent('file.txt'); + }); + }); + + group('subdirectories', () { + test('watches files in subdirectories', () async { + await startWatcher(); + writeFile('a/b/c/d/file.txt'); + await expectAddEvent('a/b/c/d/file.txt'); + }); + + test( + 'notifies when a subdirectory is moved within the watched directory ' + 'and then its contents are modified', () async { + writeFile('old/file.txt'); + await startWatcher(); + + renameDir('old', 'new'); + await inAnyOrder( + [isRemoveEvent('old/file.txt'), isAddEvent('new/file.txt')]); + + writeFile('new/file.txt', contents: 'modified'); + await expectModifyEvent('new/file.txt'); + }); + + test('notifies when a file is replaced by a subdirectory', () async { + writeFile('new'); + writeFile('old/file.txt'); + await startWatcher(); + + deleteFile('new'); + renameDir('old', 'new'); + await inAnyOrder([ + isRemoveEvent('new'), + isRemoveEvent('old/file.txt'), + isAddEvent('new/file.txt') + ]); + }); + + test('notifies when a subdirectory is replaced by a file', () async { + writeFile('old'); + writeFile('new/file.txt'); + await startWatcher(); + + renameDir('new', 'newer'); + renameFile('old', 'new'); + await inAnyOrder([ + isRemoveEvent('new/file.txt'), + isAddEvent('newer/file.txt'), + isRemoveEvent('old'), + isAddEvent('new') + ]); + }, onPlatform: { + 'windows': const Skip('https://github.com/dart-lang/watcher/issues/21') + }); + + test('emits events for many nested files added at once', () async { + withPermutations((i, j, k) => writeFile('sub/sub-$i/sub-$j/file-$k.txt')); + + createDir('dir'); + await startWatcher(path: 'dir'); + renameDir('sub', 'dir/sub'); + + await inAnyOrder(withPermutations( + (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + }); + + test('emits events for many nested files removed at once', () async { + withPermutations( + (i, j, k) => writeFile('dir/sub/sub-$i/sub-$j/file-$k.txt')); + + createDir('dir'); + await startWatcher(path: 'dir'); + + // Rename the directory rather than deleting it because native watchers + // report a rename as a single DELETE event for the directory, whereas + // they report recursive deletion with DELETE events for every file in the + // directory. + renameDir('dir/sub', 'sub'); + + await inAnyOrder(withPermutations( + (i, j, k) => isRemoveEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + }); + + test('emits events for many nested files moved at once', () async { + withPermutations( + (i, j, k) => writeFile('dir/old/sub-$i/sub-$j/file-$k.txt')); + + createDir('dir'); + await startWatcher(path: 'dir'); + renameDir('dir/old', 'dir/new'); + + await inAnyOrder(unionAll(withPermutations((i, j, k) { + return { + isRemoveEvent('dir/old/sub-$i/sub-$j/file-$k.txt'), + isAddEvent('dir/new/sub-$i/sub-$j/file-$k.txt') + }; + }))); + }); + + test( + 'emits events for many files added at once in a subdirectory with the ' + 'same name as a removed file', () async { + writeFile('dir/sub'); + withPermutations((i, j, k) => writeFile('old/sub-$i/sub-$j/file-$k.txt')); + await startWatcher(path: 'dir'); + + deleteFile('dir/sub'); + renameDir('old', 'dir/sub'); + + var events = withPermutations( + (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')); + events.add(isRemoveEvent('dir/sub')); + await inAnyOrder(events); + }); + }); +} diff --git a/pkgs/watcher/test/directory_watcher/windows_test.dart b/pkgs/watcher/test/directory_watcher/windows_test.dart new file mode 100644 index 000000000..499e7fb16 --- /dev/null +++ b/pkgs/watcher/test/directory_watcher/windows_test.dart @@ -0,0 +1,23 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('windows') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/windows.dart'; +import 'package:watcher/watcher.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = WindowsDirectoryWatcher.new; + + group('Shared Tests:', sharedTests); + + test('DirectoryWatcher creates a WindowsDirectoryWatcher on Windows', () { + expect(DirectoryWatcher('.'), const TypeMatcher()); + }); +} diff --git a/pkgs/watcher/test/file_watcher/native_test.dart b/pkgs/watcher/test/file_watcher/native_test.dart new file mode 100644 index 000000000..0d4ad6394 --- /dev/null +++ b/pkgs/watcher/test/file_watcher/native_test.dart @@ -0,0 +1,22 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('linux || mac-os') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/file_watcher/native.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = NativeFileWatcher.new; + + setUp(() { + writeFile('file.txt'); + }); + + sharedTests(); +} diff --git a/pkgs/watcher/test/file_watcher/polling_test.dart b/pkgs/watcher/test/file_watcher/polling_test.dart new file mode 100644 index 000000000..861fcb222 --- /dev/null +++ b/pkgs/watcher/test/file_watcher/polling_test.dart @@ -0,0 +1,20 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:watcher/watcher.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = (file) => + PollingFileWatcher(file, pollingDelay: const Duration(milliseconds: 100)); + + setUp(() { + writeFile('file.txt'); + }); + + sharedTests(); +} diff --git a/pkgs/watcher/test/file_watcher/shared.dart b/pkgs/watcher/test/file_watcher/shared.dart new file mode 100644 index 000000000..081b92e11 --- /dev/null +++ b/pkgs/watcher/test/file_watcher/shared.dart @@ -0,0 +1,73 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; + +import '../utils.dart'; + +void sharedTests() { + test("doesn't notify if the file isn't modified", () async { + await startWatcher(path: 'file.txt'); + await pumpEventQueue(); + deleteFile('file.txt'); + await expectRemoveEvent('file.txt'); + }); + + test('notifies when a file is modified', () async { + await startWatcher(path: 'file.txt'); + writeFile('file.txt', contents: 'modified'); + await expectModifyEvent('file.txt'); + }); + + test('notifies when a file is removed', () async { + await startWatcher(path: 'file.txt'); + deleteFile('file.txt'); + await expectRemoveEvent('file.txt'); + }); + + test('notifies when a file is modified multiple times', () async { + await startWatcher(path: 'file.txt'); + writeFile('file.txt', contents: 'modified'); + await expectModifyEvent('file.txt'); + writeFile('file.txt', contents: 'modified again'); + await expectModifyEvent('file.txt'); + }); + + test('notifies even if the file contents are unchanged', () async { + await startWatcher(path: 'file.txt'); + writeFile('file.txt'); + await expectModifyEvent('file.txt'); + }); + + test('emits a remove event when the watched file is moved away', () async { + await startWatcher(path: 'file.txt'); + renameFile('file.txt', 'new.txt'); + await expectRemoveEvent('file.txt'); + }); + + test( + 'emits a modify event when another file is moved on top of the watched ' + 'file', () async { + writeFile('old.txt'); + await startWatcher(path: 'file.txt'); + renameFile('old.txt', 'file.txt'); + await expectModifyEvent('file.txt'); + }); + + // Regression test for a race condition. + test('closes the watcher immediately after deleting the file', () async { + writeFile('old.txt'); + var watcher = createWatcher(path: 'file.txt'); + var sub = watcher.events.listen(null); + + deleteFile('file.txt'); + await Future.delayed(const Duration(milliseconds: 10)); + await sub.cancel(); + }); + + test('ready completes even if file does not exist', () async { + // startWatcher awaits 'ready' + await startWatcher(path: 'foo/bar/baz'); + }); +} diff --git a/pkgs/watcher/test/no_subscription/linux_test.dart b/pkgs/watcher/test/no_subscription/linux_test.dart new file mode 100644 index 000000000..aac08101b --- /dev/null +++ b/pkgs/watcher/test/no_subscription/linux_test.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('linux') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/linux.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = LinuxDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/no_subscription/mac_os_test.dart b/pkgs/watcher/test/no_subscription/mac_os_test.dart new file mode 100644 index 000000000..55a83087c --- /dev/null +++ b/pkgs/watcher/test/no_subscription/mac_os_test.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('mac-os') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/mac_os.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = MacOSDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/no_subscription/polling_test.dart b/pkgs/watcher/test/no_subscription/polling_test.dart new file mode 100644 index 000000000..bfd29588f --- /dev/null +++ b/pkgs/watcher/test/no_subscription/polling_test.dart @@ -0,0 +1,14 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:watcher/watcher.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = PollingDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/no_subscription/shared.dart b/pkgs/watcher/test/no_subscription/shared.dart new file mode 100644 index 000000000..e7a614454 --- /dev/null +++ b/pkgs/watcher/test/no_subscription/shared.dart @@ -0,0 +1,54 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:async/async.dart'; +import 'package:test/test.dart'; +import 'package:watcher/watcher.dart'; + +import '../utils.dart'; + +void sharedTests() { + test('does not notify for changes when there are no subscribers', () async { + // Note that this test doesn't rely as heavily on the test functions in + // utils.dart because it needs to be very explicit about when the event + // stream is and is not subscribed. + var watcher = createWatcher(); + var queue = StreamQueue(watcher.events); + unawaited(queue.hasNext); + + var future = + expectLater(queue, emits(isWatchEvent(ChangeType.ADD, 'file.txt'))); + expect(queue, neverEmits(anything)); + + await watcher.ready; + + writeFile('file.txt'); + + await future; + + // Unsubscribe. + await queue.cancel(immediate: true); + + // Now write a file while we aren't listening. + writeFile('unwatched.txt'); + + queue = StreamQueue(watcher.events); + future = + expectLater(queue, emits(isWatchEvent(ChangeType.ADD, 'added.txt'))); + expect(queue, neverEmits(isWatchEvent(ChangeType.ADD, 'unwatched.txt'))); + + // Wait until the watcher is ready to dispatch events again. + await watcher.ready; + + // And add a third file. + writeFile('added.txt'); + + // Wait until we get an event for the third file. + await future; + + await queue.cancel(immediate: true); + }); +} diff --git a/pkgs/watcher/test/no_subscription/windows_test.dart b/pkgs/watcher/test/no_subscription/windows_test.dart new file mode 100644 index 000000000..9f9e5a9c3 --- /dev/null +++ b/pkgs/watcher/test/no_subscription/windows_test.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('windows') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/windows.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = WindowsDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/path_set_test.dart b/pkgs/watcher/test/path_set_test.dart new file mode 100644 index 000000000..61ab2cd64 --- /dev/null +++ b/pkgs/watcher/test/path_set_test.dart @@ -0,0 +1,228 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:watcher/src/path_set.dart'; + +Matcher containsPath(String path) => predicate( + (paths) => paths is PathSet && paths.contains(path), + 'set contains "$path"'); + +Matcher containsDir(String path) => predicate( + (paths) => paths is PathSet && paths.containsDir(path), + 'set contains directory "$path"'); + +void main() { + late PathSet paths; + setUp(() => paths = PathSet('root')); + + group('adding a path', () { + test('stores the path in the set', () { + paths.add('root/path/to/file'); + expect(paths, containsPath('root/path/to/file')); + }); + + test("that's a subdir of another path keeps both in the set", () { + paths.add('root/path'); + paths.add('root/path/to/file'); + expect(paths, containsPath('root/path')); + expect(paths, containsPath('root/path/to/file')); + }); + + test("that's not normalized normalizes the path before storing it", () { + paths.add('root/../root/path/to/../to/././file'); + expect(paths, containsPath('root/path/to/file')); + }); + + test("that's absolute normalizes the path before storing it", () { + paths.add(p.absolute('root/path/to/file')); + expect(paths, containsPath('root/path/to/file')); + }); + }); + + group('removing a path', () { + test("that's in the set removes and returns that path", () { + paths.add('root/path/to/file'); + expect(paths.remove('root/path/to/file'), + unorderedEquals([p.normalize('root/path/to/file')])); + expect(paths, isNot(containsPath('root/path/to/file'))); + }); + + test("that's not in the set returns an empty set", () { + paths.add('root/path/to/file'); + expect(paths.remove('root/path/to/nothing'), isEmpty); + }); + + test("that's a directory removes and returns all files beneath it", () { + paths.add('root/outside'); + paths.add('root/path/to/one'); + paths.add('root/path/to/two'); + paths.add('root/path/to/sub/three'); + + expect( + paths.remove('root/path'), + unorderedEquals([ + 'root/path/to/one', + 'root/path/to/two', + 'root/path/to/sub/three' + ].map(p.normalize))); + + expect(paths, containsPath('root/outside')); + expect(paths, isNot(containsPath('root/path/to/one'))); + expect(paths, isNot(containsPath('root/path/to/two'))); + expect(paths, isNot(containsPath('root/path/to/sub/three'))); + }); + + test( + "that's a directory in the set removes and returns it and all files " + 'beneath it', () { + paths.add('root/path'); + paths.add('root/path/to/one'); + paths.add('root/path/to/two'); + paths.add('root/path/to/sub/three'); + + expect( + paths.remove('root/path'), + unorderedEquals([ + 'root/path', + 'root/path/to/one', + 'root/path/to/two', + 'root/path/to/sub/three' + ].map(p.normalize))); + + expect(paths, isNot(containsPath('root/path'))); + expect(paths, isNot(containsPath('root/path/to/one'))); + expect(paths, isNot(containsPath('root/path/to/two'))); + expect(paths, isNot(containsPath('root/path/to/sub/three'))); + }); + + test("that's not normalized removes and returns the normalized path", () { + paths.add('root/path/to/file'); + expect(paths.remove('root/../root/path/to/../to/./file'), + unorderedEquals([p.normalize('root/path/to/file')])); + }); + + test("that's absolute removes and returns the normalized path", () { + paths.add('root/path/to/file'); + expect(paths.remove(p.absolute('root/path/to/file')), + unorderedEquals([p.normalize('root/path/to/file')])); + }); + }); + + group('containsPath()', () { + test('returns false for a non-existent path', () { + paths.add('root/path/to/file'); + expect(paths, isNot(containsPath('root/path/to/nothing'))); + }); + + test("returns false for a directory that wasn't added explicitly", () { + paths.add('root/path/to/file'); + expect(paths, isNot(containsPath('root/path'))); + }); + + test('returns true for a directory that was added explicitly', () { + paths.add('root/path'); + paths.add('root/path/to/file'); + expect(paths, containsPath('root/path')); + }); + + test('with a non-normalized path normalizes the path before looking it up', + () { + paths.add('root/path/to/file'); + expect(paths, containsPath('root/../root/path/to/../to/././file')); + }); + + test('with an absolute path normalizes the path before looking it up', () { + paths.add('root/path/to/file'); + expect(paths, containsPath(p.absolute('root/path/to/file'))); + }); + }); + + group('containsDir()', () { + test('returns true for a directory that was added implicitly', () { + paths.add('root/path/to/file'); + expect(paths, containsDir('root/path')); + expect(paths, containsDir('root/path/to')); + }); + + test('returns true for a directory that was added explicitly', () { + paths.add('root/path'); + paths.add('root/path/to/file'); + expect(paths, containsDir('root/path')); + }); + + test("returns false for a directory that wasn't added", () { + expect(paths, isNot(containsDir('root/nothing'))); + }); + + test('returns false for a non-directory path that was added', () { + paths.add('root/path/to/file'); + expect(paths, isNot(containsDir('root/path/to/file'))); + }); + + test( + 'returns false for a directory that was added implicitly and then ' + 'removed implicitly', () { + paths.add('root/path/to/file'); + paths.remove('root/path/to/file'); + expect(paths, isNot(containsDir('root/path'))); + }); + + test( + 'returns false for a directory that was added explicitly whose ' + 'children were then removed', () { + paths.add('root/path'); + paths.add('root/path/to/file'); + paths.remove('root/path/to/file'); + expect(paths, isNot(containsDir('root/path'))); + }); + + test('with a non-normalized path normalizes the path before looking it up', + () { + paths.add('root/path/to/file'); + expect(paths, containsDir('root/../root/path/to/../to/.')); + }); + + test('with an absolute path normalizes the path before looking it up', () { + paths.add('root/path/to/file'); + expect(paths, containsDir(p.absolute('root/path'))); + }); + }); + + group('paths', () { + test('returns paths added to the set', () { + paths.add('root/path'); + paths.add('root/path/to/one'); + paths.add('root/path/to/two'); + + expect( + paths.paths, + unorderedEquals([ + 'root/path', + 'root/path/to/one', + 'root/path/to/two', + ].map(p.normalize))); + }); + + test("doesn't return paths removed from the set", () { + paths.add('root/path/to/one'); + paths.add('root/path/to/two'); + paths.remove('root/path/to/two'); + + expect(paths.paths, unorderedEquals([p.normalize('root/path/to/one')])); + }); + }); + + group('clear', () { + test('removes all paths from the set', () { + paths.add('root/path'); + paths.add('root/path/to/one'); + paths.add('root/path/to/two'); + + paths.clear(); + expect(paths.paths, isEmpty); + }); + }); +} diff --git a/pkgs/watcher/test/ready/linux_test.dart b/pkgs/watcher/test/ready/linux_test.dart new file mode 100644 index 000000000..aac08101b --- /dev/null +++ b/pkgs/watcher/test/ready/linux_test.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('linux') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/linux.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = LinuxDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/ready/mac_os_test.dart b/pkgs/watcher/test/ready/mac_os_test.dart new file mode 100644 index 000000000..55a83087c --- /dev/null +++ b/pkgs/watcher/test/ready/mac_os_test.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('mac-os') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/mac_os.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = MacOSDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/ready/polling_test.dart b/pkgs/watcher/test/ready/polling_test.dart new file mode 100644 index 000000000..bfd29588f --- /dev/null +++ b/pkgs/watcher/test/ready/polling_test.dart @@ -0,0 +1,14 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:watcher/watcher.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = PollingDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/ready/shared.dart b/pkgs/watcher/test/ready/shared.dart new file mode 100644 index 000000000..ab2c3e162 --- /dev/null +++ b/pkgs/watcher/test/ready/shared.dart @@ -0,0 +1,84 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:test/test.dart'; + +import '../utils.dart'; + +void sharedTests() { + test('ready does not complete until after subscription', () async { + var watcher = createWatcher(); + + var ready = false; + unawaited(watcher.ready.then((_) { + ready = true; + })); + await pumpEventQueue(); + + expect(ready, isFalse); + + // Subscribe to the events. + var subscription = watcher.events.listen((event) {}); + + await watcher.ready; + + // Should eventually be ready. + expect(watcher.isReady, isTrue); + + await subscription.cancel(); + }); + + test('ready completes immediately when already ready', () async { + var watcher = createWatcher(); + + // Subscribe to the events. + var subscription = watcher.events.listen((event) {}); + + // Allow watcher to become ready + await watcher.ready; + + // Ensure ready completes immediately + expect( + watcher.ready.timeout( + const Duration(milliseconds: 0), + onTimeout: () => throw StateError('Does not complete immediately'), + ), + completes, + ); + + await subscription.cancel(); + }); + + test('ready returns a future that does not complete after unsubscribing', + () async { + var watcher = createWatcher(); + + // Subscribe to the events. + var subscription = watcher.events.listen((event) {}); + + // Wait until ready. + await watcher.ready; + + // Now unsubscribe. + await subscription.cancel(); + + // Should be back to not ready. + expect(watcher.ready, doesNotComplete); + }); + + test('ready completes even if directory does not exist', () async { + var watcher = createWatcher(path: 'does/not/exist'); + + // Subscribe to the events (else ready will never fire). + var subscription = watcher.events.listen((event) {}, onError: (error) {}); + + // Expect ready still completes. + await watcher.ready; + + // Now unsubscribe. + await subscription.cancel(); + }); +} diff --git a/pkgs/watcher/test/ready/windows_test.dart b/pkgs/watcher/test/ready/windows_test.dart new file mode 100644 index 000000000..9f9e5a9c3 --- /dev/null +++ b/pkgs/watcher/test/ready/windows_test.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('windows') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/windows.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = WindowsDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/utils.dart b/pkgs/watcher/test/utils.dart new file mode 100644 index 000000000..7867b9fc2 --- /dev/null +++ b/pkgs/watcher/test/utils.dart @@ -0,0 +1,288 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; +import 'package:watcher/src/stat.dart'; +import 'package:watcher/watcher.dart'; + +typedef WatcherFactory = Watcher Function(String directory); + +/// Sets the function used to create the watcher. +set watcherFactory(WatcherFactory factory) { + _watcherFactory = factory; +} + +/// The mock modification times (in milliseconds since epoch) for each file. +/// +/// The actual file system has pretty coarse granularity for file modification +/// times. This means using the real file system requires us to put delays in +/// the tests to ensure we wait long enough between operations for the mod time +/// to be different. +/// +/// Instead, we'll just mock that out. Each time a file is written, we manually +/// increment the mod time for that file instantly. +final _mockFileModificationTimes = {}; + +late WatcherFactory _watcherFactory; + +/// Creates a new [Watcher] that watches a temporary file or directory. +/// +/// If [path] is provided, watches a subdirectory in the sandbox with that name. +Watcher createWatcher({String? path}) { + if (path == null) { + path = d.sandbox; + } else { + path = p.join(d.sandbox, path); + } + + return _watcherFactory(path); +} + +/// The stream of events from the watcher started with [startWatcher]. +late StreamQueue _watcherEvents; + +/// Whether the event stream has been closed. +/// +/// If this is not done by a test (by calling [startClosingEventStream]) it will +/// be done automatically via [addTearDown] in [startWatcher]. +var _hasClosedStream = true; + +/// Creates a new [Watcher] that watches a temporary file or directory and +/// starts monitoring it for events. +/// +/// If [path] is provided, watches a path in the sandbox with that name. +Future startWatcher({String? path}) async { + mockGetModificationTime((path) { + final normalized = p.normalize(p.relative(path, from: d.sandbox)); + + // Make sure we got a path in the sandbox. + assert(p.isRelative(normalized) && !normalized.startsWith('..'), + 'Path is not in the sandbox: $path not in ${d.sandbox}'); + + var mtime = _mockFileModificationTimes[normalized]; + return mtime != null ? DateTime.fromMillisecondsSinceEpoch(mtime) : null; + }); + + // We want to wait until we're ready *after* we subscribe to the watcher's + // events. + var watcher = createWatcher(path: path); + _watcherEvents = StreamQueue(watcher.events); + // Forces a subscription to the underlying stream. + unawaited(_watcherEvents.hasNext); + + _hasClosedStream = false; + addTearDown(startClosingEventStream); + + await watcher.ready; +} + +/// Schedule closing the watcher stream after the event queue has been pumped. +/// +/// This is necessary when events are allowed to occur, but don't have to occur, +/// at the end of a test. Otherwise, if they don't occur, the test will wait +/// indefinitely because they might in the future and because the watcher is +/// normally only closed after the test completes. +void startClosingEventStream() async { + if (_hasClosedStream) return; + _hasClosedStream = true; + await pumpEventQueue(); + await _watcherEvents.cancel(immediate: true); +} + +/// A list of [StreamMatcher]s that have been collected using +/// [_collectStreamMatcher]. +List? _collectedStreamMatchers; + +/// Collects all stream matchers that are registered within [block] into a +/// single stream matcher. +/// +/// The returned matcher will match each of the collected matchers in order. +StreamMatcher _collectStreamMatcher(void Function() block) { + var oldStreamMatchers = _collectedStreamMatchers; + var collectedStreamMatchers = _collectedStreamMatchers = []; + try { + block(); + return emitsInOrder(collectedStreamMatchers); + } finally { + _collectedStreamMatchers = oldStreamMatchers; + } +} + +/// Either add [streamMatcher] as an expectation to [_watcherEvents], or collect +/// it with [_collectStreamMatcher]. +/// +/// [streamMatcher] can be a [StreamMatcher], a [Matcher], or a value. +Future _expectOrCollect(Matcher streamMatcher) { + var collectedStreamMatchers = _collectedStreamMatchers; + if (collectedStreamMatchers != null) { + collectedStreamMatchers.add(emits(streamMatcher)); + return Future.sync(() {}); + } else { + return expectLater(_watcherEvents, emits(streamMatcher)); + } +} + +/// Expects that [matchers] will match emitted events in any order. +/// +/// [matchers] may be [Matcher]s or values, but not [StreamMatcher]s. +Future inAnyOrder(Iterable matchers) { + matchers = matchers.toSet(); + return _expectOrCollect(emitsInAnyOrder(matchers)); +} + +/// Expects that the expectations established in either [block1] or [block2] +/// will match the emitted events. +/// +/// If both blocks match, the one that consumed more events will be used. +Future allowEither(void Function() block1, void Function() block2) => + _expectOrCollect(emitsAnyOf( + [_collectStreamMatcher(block1), _collectStreamMatcher(block2)])); + +/// Allows the expectations established in [block] to match the emitted events. +/// +/// If the expectations in [block] don't match, no error will be raised and no +/// events will be consumed. If this is used at the end of a test, +/// [startClosingEventStream] should be called before it. +Future allowEvents(void Function() block) => + _expectOrCollect(mayEmit(_collectStreamMatcher(block))); + +/// Returns a StreamMatcher that matches a [WatchEvent] with the given [type] +/// and [path]. +Matcher isWatchEvent(ChangeType type, String path) { + return predicate((e) { + return e is WatchEvent && + e.type == type && + e.path == p.join(d.sandbox, p.normalize(path)); + }, 'is $type $path'); +} + +/// Returns a [Matcher] that matches a [WatchEvent] for an add event for [path]. +Matcher isAddEvent(String path) => isWatchEvent(ChangeType.ADD, path); + +/// Returns a [Matcher] that matches a [WatchEvent] for a modification event for +/// [path]. +Matcher isModifyEvent(String path) => isWatchEvent(ChangeType.MODIFY, path); + +/// Returns a [Matcher] that matches a [WatchEvent] for a removal event for +/// [path]. +Matcher isRemoveEvent(String path) => isWatchEvent(ChangeType.REMOVE, path); + +/// Expects that the next event emitted will be for an add event for [path]. +Future expectAddEvent(String path) => + _expectOrCollect(isWatchEvent(ChangeType.ADD, path)); + +/// Expects that the next event emitted will be for a modification event for +/// [path]. +Future expectModifyEvent(String path) => + _expectOrCollect(isWatchEvent(ChangeType.MODIFY, path)); + +/// Expects that the next event emitted will be for a removal event for [path]. +Future expectRemoveEvent(String path) => + _expectOrCollect(isWatchEvent(ChangeType.REMOVE, path)); + +/// Consumes a modification event for [path] if one is emitted at this point in +/// the schedule, but doesn't throw an error if it isn't. +/// +/// If this is used at the end of a test, [startClosingEventStream] should be +/// called before it. +Future allowModifyEvent(String path) => + _expectOrCollect(mayEmit(isWatchEvent(ChangeType.MODIFY, path))); + +/// Track a fake timestamp to be used when writing files. This always increases +/// so that files that are deleted and re-created do not have their timestamp +/// set back to a previously used value. +int _nextTimestamp = 1; + +/// Schedules writing a file in the sandbox at [path] with [contents]. +/// +/// If [contents] is omitted, creates an empty file. If [updateModified] is +/// `false`, the mock file modification time is not changed. +void writeFile(String path, {String? contents, bool? updateModified}) { + contents ??= ''; + updateModified ??= true; + + var fullPath = p.join(d.sandbox, path); + + // Create any needed subdirectories. + var dir = Directory(p.dirname(fullPath)); + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + + File(fullPath).writeAsStringSync(contents); + + if (updateModified) { + path = p.normalize(path); + + _mockFileModificationTimes[path] = _nextTimestamp++; + } +} + +/// Schedules deleting a file in the sandbox at [path]. +void deleteFile(String path) { + File(p.join(d.sandbox, path)).deleteSync(); + + _mockFileModificationTimes.remove(path); +} + +/// Schedules renaming a file in the sandbox from [from] to [to]. +void renameFile(String from, String to) { + File(p.join(d.sandbox, from)).renameSync(p.join(d.sandbox, to)); + + // Make sure we always use the same separator on Windows. + to = p.normalize(to); + + _mockFileModificationTimes.update(to, (value) => value + 1, + ifAbsent: () => 1); +} + +/// Schedules creating a directory in the sandbox at [path]. +void createDir(String path) { + Directory(p.join(d.sandbox, path)).createSync(); +} + +/// Schedules renaming a directory in the sandbox from [from] to [to]. +void renameDir(String from, String to) { + Directory(p.join(d.sandbox, from)).renameSync(p.join(d.sandbox, to)); + + // Migrate timestamps for any files in this folder. + final knownFilePaths = _mockFileModificationTimes.keys.toList(); + for (final filePath in knownFilePaths) { + if (p.isWithin(from, filePath)) { + _mockFileModificationTimes[filePath.replaceAll(from, to)] = + _mockFileModificationTimes[filePath]!; + _mockFileModificationTimes.remove(filePath); + } + } +} + +/// Schedules deleting a directory in the sandbox at [path]. +void deleteDir(String path) { + Directory(p.join(d.sandbox, path)).deleteSync(recursive: true); +} + +/// Runs [callback] with every permutation of non-negative numbers for each +/// argument less than [limit]. +/// +/// Returns a set of all values returns by [callback]. +/// +/// [limit] defaults to 3. +Set withPermutations(S Function(int, int, int) callback, {int? limit}) { + limit ??= 3; + var results = {}; + for (var i = 0; i < limit; i++) { + for (var j = 0; j < limit; j++) { + for (var k = 0; k < limit; k++) { + results.add(callback(i, j, k)); + } + } + } + return results; +}