Repository: korandoru/hawkeye Branch: main Commit: 3c39666cbb58 Files: 76 Total size: 182.6 KB Directory structure: gitextract_xj1rciio/ ├── .cargo/ │ └── config.toml ├── .dockerignore ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ ├── actions/ │ │ ├── docker-push-by-digest/ │ │ │ └── action.yml │ │ └── docker-release/ │ │ └── action.yml │ ├── semantic.yml │ └── workflows/ │ ├── ci.yml │ ├── docker.yml │ └── release.yml ├── .gitignore ├── .pre-commit-hooks.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── action.yml ├── cli/ │ ├── Cargo.toml │ ├── build.rs │ └── src/ │ ├── main.rs │ ├── subcommand.rs │ └── version.rs ├── fmt/ │ ├── Cargo.toml │ ├── src/ │ │ ├── config/ │ │ │ └── mod.rs │ │ ├── document/ │ │ │ ├── defaults.toml │ │ │ ├── factory.rs │ │ │ ├── mod.rs │ │ │ └── model.rs │ │ ├── error.rs │ │ ├── git.rs │ │ ├── header/ │ │ │ ├── defaults.toml │ │ │ ├── matcher.rs │ │ │ ├── mod.rs │ │ │ ├── model.rs │ │ │ └── parser.rs │ │ ├── lib.rs │ │ ├── license/ │ │ │ ├── Apache-2.0-ASF.txt │ │ │ ├── Apache-2.0.txt │ │ │ ├── Elastic-2.0.txt │ │ │ └── mod.rs │ │ ├── processor.rs │ │ └── selection.rs │ └── tests/ │ ├── content/ │ │ ├── empty.py │ │ └── two_headers.rs │ └── tests.rs ├── licenserc.toml ├── pyproject.toml ├── rust-toolchain.toml ├── rustfmt.toml └── tests/ ├── .gitignore ├── attrs_and_props/ │ ├── license.txt │ ├── licenserc.toml │ ├── main.rs │ └── main.rs.expected ├── bom_issue/ │ ├── headless_bom.cs │ ├── headless_bom.cs.expected │ ├── license.txt │ ├── licenserc.toml │ └── style.toml ├── disk_file_created_year/ │ ├── license.txt │ ├── licenserc.toml │ ├── main.rs │ └── main.rs.expected ├── it.py ├── load_header_path/ │ ├── license.txt │ ├── licenserc.toml │ ├── main.rs │ └── main.rs.expected ├── regression_blank_line/ │ ├── licenserc.toml │ ├── main.rs │ └── main.rs.expected └── regression_no_blank_lines/ ├── licenserc.toml ├── repro.py └── repro.py.expected ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cargo/config.toml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # See also https://github.com/rust-lang/rust/issues/44991 [target.x86_64-unknown-linux-musl] rustflags = ["-C", "target-feature=-crt-static"] [target.aarch64-unknown-linux-musl] rustflags = ["-C", "target-feature=-crt-static"] ================================================ FILE: .dockerignore ================================================ target/ !**/src/main/**/target/ !**/src/test/**/target/ dependency-reduced-pom.xml ### IntelliJ IDEA ### .idea/modules.xml .idea/jarRepositories.xml .idea/compiler.xml .idea/libraries/ *.iws *.iml *.ipr ### Mac OS ### .DS_Store ================================================ FILE: .editorconfig ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. root = true [*] end_of_line = lf indent_style = space insert_final_newline = true trim_trailing_whitespace = true [*.txt] insert_final_newline = false ================================================ FILE: .github/FUNDING.yml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. github: tisonkun ================================================ FILE: .github/actions/docker-push-by-digest/action.yml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. name: Docker push by digest description: Build and push Docker image by digest inputs: name: description: The name of Docker image required: true file: description: The name of Dockerfile in use required: true outputs: digest: description: Docker image digest if pushed value: ${{ steps.push.outputs.digest }} runs: using: composite steps: - uses: docker/setup-buildx-action@v2 - uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - name: Docker meta id: meta uses: docker/metadata-action@v4 with: images: ghcr.io/${{ github.repository_owner }}/${{ inputs.name }} - name: Build and push id: push uses: docker/build-push-action@v5 with: context: . file: ${{ inputs.file }} tags: ghcr.io/${{ github.repository_owner }}/${{ inputs.name }} labels: ${{ steps.meta.outputs.labels }} outputs: type=image,push=true,push-by-digest=true ================================================ FILE: .github/actions/docker-release/action.yml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. name: Docker release description: Release Docker Images inputs: name: description: The name of Docker image required: true digests: description: The digest of images to be merged required: true runs: using: composite steps: - uses: docker/setup-buildx-action@v2 - uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - name: Docker meta id: meta uses: docker/metadata-action@v4 with: images: ghcr.io/${{ github.repository_owner }}/${{ inputs.name }} sep-tags: ' ' tags: | type=semver,pattern={{raw}} type=semver,pattern=v{{major}} type=edge,branch=main - name: Push manifest shell: bash run: | for tag in ${{ steps.meta.outputs.tags }}; do echo "Preparing manifest for tag: $tag..." docker buildx imagetools create -t $tag ${{ inputs.digests }} done ================================================ FILE: .github/semantic.yml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # The pull request's title should be fulfilled the following pattern: # # [optional scope]: # # ... where valid types and scopes can be found below; for example: # # build(maven): One level down for native profile # # More about configurations on https://github.com/Ezard/semantic-prs#configuration enabled: true titleOnly: true types: - feat - fix - docs - style - refactor - perf - test - build - ci - chore - revert targetUrl: https://github.com/korandoru/hawkeye/blob/main/.github/semantic.yml ================================================ FILE: .github/workflows/ci.yml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. name: CI on: pull_request: branches: [ main ] push: branches: [ main ] # Concurrency strategy: # github.workflow: distinguish this workflow from others # github.event_name: distinguish `push` event from `pull_request` event # github.event.number: set to the number of the pull request if `pull_request` event # github.run_id: otherwise, it's a `push` event, only cancel if we rerun the workflow # # Reference: # https://docs.github.com/en/actions/using-jobs/using-concurrency # https://docs.github.com/en/actions/learn-github-actions/contexts#github-context concurrency: group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.number || github.run_id }} cancel-in-progress: true jobs: check: name: Check runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 - uses: dtolnay/rust-toolchain@nightly - name: Check Clippy run: cargo clippy --tests --all-features --all-targets --workspace -- -D warnings - name: Check format run: cargo fmt --all --check test: strategy: matrix: rust-version: ["1.90.0", "stable"] name: Build and test runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 - name: Delete rust-toolchain.toml run: rm rust-toolchain.toml - run: cargo build --workspace --all-features --bins --tests --examples --benches - name: Run tests run: cargo test --workspace -- --nocapture - name: Run benches run: cargo bench --workspace -- --test docker: strategy: fail-fast: false matrix: os: [ ubuntu-24.04 ] runs-on: ${{matrix.os}} name: Docker sanity check on ${{ matrix.os }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Build and load uses: docker/build-push-action@v5 with: context: . file: Dockerfile tags: hawkeye:ci outputs: type=docker - name: Save image run: docker save hawkeye:ci -o /tmp/hawkeye-${{matrix.os}}.tar - name: Upload artifact uses: actions/upload-artifact@v4 with: name: hawkeye-${{matrix.os}} path: /tmp/hawkeye-${{matrix.os}}.tar - name: Sanity check run: | cp action.yml action.yml.bak docker image inspect hawkeye:ci --format='{{.Size}}' docker run --rm -v $(pwd):/github/workspace hawkeye:ci -V docker run --rm -v $(pwd):/github/workspace hawkeye:ci check --fail-if-unknown smoke: strategy: fail-fast: false matrix: os: [ubuntu-24.04, windows-2022, macos-14] runs-on: ${{ matrix.os }} name: Smoke test on ${{ matrix.os }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: Swatinem/rust-cache@v2 - name: Dog feed license check shell: bash run: | cargo run --bin hawkeye -- -V cargo run --bin hawkeye -- check --fail-if-unknown - uses: actions/setup-python@v5 with: python-version: '3.12' - name: Run integration tests shell: bash run: ./tests/it.py gha: strategy: fail-fast: false matrix: os: [ubuntu-24.04, windows-2022, macos-14] runs-on: ${{ matrix.os }} name: Smoke test for GitHub Actions on ${{ matrix.os }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Check and fail if unknown uses: ./ with: args: --fail-if-unknown - name: Check default config works uses: ./ required: name: Required runs-on: ubuntu-24.04 if: ${{ always() }} needs: - check - docker - gha - smoke - test steps: - name: Guardian run: | if [[ ! ( \ "${{ needs.check.result }}" == "success" \ && "${{ needs.docker.result }}" == "success" \ && "${{ needs.gha.result }}" == "success" \ && "${{ needs.smoke.result }}" == "success" \ && "${{ needs.test.result }}" == "success" \ ) ]]; then echo "Required jobs haven't been completed successfully." exit -1 fi ================================================ FILE: .github/workflows/docker.yml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. name: Build and Push Docker Images on: push: branches: ['main'] tags: ['v*.*'] workflow_dispatch: jobs: build-and-push-hawkeye-amd64: runs-on: ubuntu-24.04 permissions: packages: write steps: - uses: actions/checkout@v4 - name: Build and push by digest uses: ./.github/actions/docker-push-by-digest id: build with: name: hawkeye file: Dockerfile outputs: digest: ${{ steps.build.outputs.digest }} build-and-push-hawkeye-arm64: runs-on: ubuntu-24.04-arm permissions: packages: write steps: - uses: actions/checkout@v4 - name: Build and push by digest uses: ./.github/actions/docker-push-by-digest id: build with: name: hawkeye file: Dockerfile outputs: digest: ${{ steps.build.outputs.digest }} release-hawkeye: runs-on: ubuntu-24.04 permissions: packages: write needs: - build-and-push-hawkeye-amd64 - build-and-push-hawkeye-arm64 steps: - uses: actions/checkout@v4 - name: Merge and push manifest uses: ./.github/actions/docker-release with: name: hawkeye digests: > ${{needs.build-and-push-hawkeye-amd64.outputs.digest}} ${{needs.build-and-push-hawkeye-arm64.outputs.digest}} release-native: runs-on: ubuntu-24.04 permissions: packages: write needs: - build-and-push-hawkeye-amd64 - build-and-push-hawkeye-arm64 steps: - uses: actions/checkout@v4 - name: Merge and push manifest uses: ./.github/actions/docker-release with: name: hawkeye-native digests: > ghcr.io/${{ github.repository_owner }}/hawkeye@${{needs.build-and-push-hawkeye-amd64.outputs.digest}} ghcr.io/${{ github.repository_owner }}/hawkeye@${{needs.build-and-push-hawkeye-arm64.outputs.digest}} ================================================ FILE: .github/workflows/release.yml ================================================ # This file was autogenerated by cargo-dist: https://opensource.axo.dev/cargo-dist/ # # Copyright 2022-2024, axodotdev # SPDX-License-Identifier: MIT or Apache-2.0 # # CI that: # # * checks for a Git Tag that looks like a release # * builds artifacts with cargo-dist (archives, installers, hashes) # * uploads those artifacts to temporary workflow zip # * on success, uploads the artifacts to a GitHub Release # # Note that the GitHub Release will be created with a generated # title/body based on your changelogs. name: Release permissions: "contents": "write" # This task will run whenever you push a git tag that looks like a version # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION # must be a Cargo-style SemVer Version (must have at least major.minor.patch). # # If PACKAGE_NAME is specified, then the announcement will be for that # package (erroring out if it doesn't have the given version or isn't cargo-dist-able). # # If PACKAGE_NAME isn't specified, then the announcement will be for all # (cargo-dist-able) packages in the workspace with that version (this mode is # intended for workspaces with only one dist-able package, or with all dist-able # packages versioned/released in lockstep). # # If you push multiple tags at once, separate instances of this workflow will # spin up, creating an independent announcement for each one. However, GitHub # will hard limit this to 3 tags per commit, as it will assume more tags is a # mistake. # # If there's a prerelease-style suffix to the version, then the release(s) # will be marked as a prerelease. on: pull_request: push: tags: - '**[0-9]+.[0-9]+.[0-9]+*' jobs: # Run 'cargo dist plan' (or host) to determine what tasks we need to do plan: runs-on: "ubuntu-22.04" outputs: val: ${{ steps.plan.outputs.manifest }} tag: ${{ !github.event.pull_request && github.ref_name || '' }} tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} publishing: ${{ !github.event.pull_request }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v4 with: submodules: recursive - name: Install cargo-dist # we specify bash to get pipefail; it guards against the `curl` command # failing. otherwise `sh` won't catch that `curl` returned non-0 shell: bash run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.22.1/cargo-dist-installer.sh | sh" - name: Cache cargo-dist uses: actions/upload-artifact@v4 with: name: cargo-dist-cache path: ~/.cargo/bin/cargo-dist # sure would be cool if github gave us proper conditionals... # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible # functionality based on whether this is a pull_request, and whether it's from a fork. # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* # but also really annoying to build CI around when it needs secrets to work right.) - id: plan run: | cargo dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json echo "cargo dist ran successfully" cat plan-dist-manifest.json echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" uses: actions/upload-artifact@v4 with: name: artifacts-plan-dist-manifest path: plan-dist-manifest.json # Build and packages all the platform-specific things build-local-artifacts: name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) # Let the initial task tell us to not run (currently very blunt) needs: - plan if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} strategy: fail-fast: false # Target platforms/runners are computed by cargo-dist in create-release. # Each member of the matrix has the following arguments: # # - runner: the github runner # - dist-args: cli flags to pass to cargo dist # - install-dist: expression to run to install cargo-dist on the runner # # Typically there will be: # - 1 "global" task that builds universal installers # - N "local" tasks that build each platform's binaries and platform-specific installers matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} runs-on: ${{ matrix.runner }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json steps: - name: enable windows longpaths run: | git config --global core.longpaths true - uses: actions/checkout@v4 with: submodules: recursive - name: Install cargo-dist run: ${{ matrix.install_dist }} # Get the dist-manifest - name: Fetch local artifacts uses: actions/download-artifact@v4 with: pattern: artifacts-* path: target/distrib/ merge-multiple: true - name: Install dependencies run: | ${{ matrix.packages_install }} - name: Build artifacts run: | # Actually do builds and make zips and whatnot cargo dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json echo "cargo dist ran successfully" - id: cargo-dist name: Post-build # We force bash here just because github makes it really hard to get values up # to "real" actions without writing to env-vars, and writing to env-vars has # inconsistent syntax between shell and powershell. shell: bash run: | # Parse out what we just built and upload it to scratch storage echo "paths<> "$GITHUB_OUTPUT" jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" uses: actions/upload-artifact@v4 with: name: artifacts-build-local-${{ join(matrix.targets, '_') }} path: | ${{ steps.cargo-dist.outputs.paths }} ${{ env.BUILD_MANIFEST_NAME }} # Build and package all the platform-agnostic(ish) things build-global-artifacts: needs: - plan - build-local-artifacts runs-on: "ubuntu-22.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json steps: - uses: actions/checkout@v4 with: submodules: recursive - name: Install cached cargo-dist uses: actions/download-artifact@v4 with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/cargo-dist # Get all the local artifacts for the global tasks to use (for e.g. checksums) - name: Fetch local artifacts uses: actions/download-artifact@v4 with: pattern: artifacts-* path: target/distrib/ merge-multiple: true - id: cargo-dist shell: bash run: | cargo dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json echo "cargo dist ran successfully" # Parse out what we just built and upload it to scratch storage echo "paths<> "$GITHUB_OUTPUT" jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" uses: actions/upload-artifact@v4 with: name: artifacts-build-global path: | ${{ steps.cargo-dist.outputs.paths }} ${{ env.BUILD_MANIFEST_NAME }} # Determines if we should publish/announce host: needs: - plan - build-local-artifacts - build-global-artifacts # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} runs-on: "ubuntu-22.04" outputs: val: ${{ steps.host.outputs.manifest }} steps: - uses: actions/checkout@v4 with: submodules: recursive - name: Install cached cargo-dist uses: actions/download-artifact@v4 with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/cargo-dist # Fetch artifacts from scratch-storage - name: Fetch artifacts uses: actions/download-artifact@v4 with: pattern: artifacts-* path: target/distrib/ merge-multiple: true - id: host shell: bash run: | cargo dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json echo "artifacts uploaded and released successfully" cat dist-manifest.json echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" uses: actions/upload-artifact@v4 with: # Overwrite the previous copy name: artifacts-dist-manifest path: dist-manifest.json # Create a GitHub Release while uploading all files to it - name: "Download GitHub Artifacts" uses: actions/download-artifact@v4 with: pattern: artifacts-* path: artifacts merge-multiple: true - name: Cleanup run: | # Remove the granular manifests rm -f artifacts/*-dist-manifest.json - name: Create GitHub Release env: PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" RELEASE_COMMIT: "${{ github.sha }}" run: | # Write and read notes from a file to avoid quoting breaking things echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* announce: needs: - plan - host # use "always() && ..." to allow us to wait for all publish jobs while # still allowing individual publish jobs to skip themselves (for prereleases). # "host" however must run to completion, no skipping allowed! if: ${{ always() && needs.host.result == 'success' }} runs-on: "ubuntu-22.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v4 with: submodules: recursive ================================================ FILE: .gitignore ================================================ /target *.bak ================================================ FILE: .pre-commit-hooks.yaml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - id: hawkeye-format name: hawkeye-format description: "Run `hawkeye format` for license header formatting" entry: hawkeye format language: python args: [] pass_filenames: false require_serial: true additional_dependencies: [] minimum_pre_commit_version: "2.9.2" - id: hawkeye-format-docker name: hawkeye-format description: "Run `hawkeye format` for license header formatting" entry: hawkeye format language: docker args: [] pass_filenames: false require_serial: true additional_dependencies: [] minimum_pre_commit_version: "2.9.2" ================================================ FILE: CHANGELOG.md ================================================ # CHANGELOG All notable changes to this project will be documented in this file. ## Unreleased ## [6.5.1] 2026-02-14 ### Bug fixes * Properly resolve relative paths when populating Git attributes for untracked folders. ## [6.5.0] 2026-02-09 ### Notable changes * Minimal Supported Rust Version (MSRV) is now 1.90.0. ### Bug fixes * `hawkeye` CLI now uses hawkeye-fmt of exactly the same version to format headers, instead of using the latest version of `hawkeye-fmt` that may not be compatible with the current version of `hawkeye`. ### Improvements * Replace `anyhow` with `exn` for more informative error messages. ## [6.4.2] 2026-02-07 ## Bug fixes * Set Git attributes for untracked folders as if it were committed now. ## [6.4.1] 2026-01-13 ## Improvements * Use `TextLayout` for logging output to improve formatting and readability. ## [6.4.0] 2026-01-12 ### Notable changes * `attrs.disk_file_created_year`, `attrs.git_file_created_year`, and `attrs.git_file_modified_year` are now integers instead of strings. Most use cases should not be affected. * `attrs.git_file_created_year` is now set even if the file is not tracked by Git. In this case, it will be set to the current year (as if it were committed now). * `attrs.git_file_modified_year` is now overwritten if the file is modified but not committed by Git. In this case, it will be set to the current year (as if it were committed now). * `attrs.disk_file_created_year` is then soft-deprecated. It can still be set, but it is recommended to use `attrs.git_file_created_year` and `attrs.git_file_modified_year` directly instead. The semantic changes above are breaking, but they should not affect most users and should always be what you want. * `additionalHeaders` and `headerPath` now search from the following paths in order: 1. The directory of the configuration file, a.k.a., config_dir. 2. The configured baseDir. 3. The current working directory. ## Improvements * If `--config` is not specified, HawkEye will now search for `.licenserc.toml` in addition to `licenserc.toml`. ## [6.3.0] 2025-10-09 ### New features * Add distribution against musl libc ([#196](https://github.com/korandoru/hawkeye/pull/196)). ## [6.2.0] 2025-08-25 ### New features * Supports format Vue files: pattern = "vue" and headerType = "XML_STYLE". * Supports format Containerfile files: pattern = "Containerfile" and headerType = "SCRIPT_STYLE". * Add a shared flag to store lists of files to change ([#194](https://github.com/korandoru/hawkeye/pull/194)). ## [6.1.1] 2025-06-11 ### New features * Supports format CommonJS files: pattern = "cjs" and headerType = "SLASHSTAR_STYLE". * Supports format Verilog files: pattern = "v" and headerType = "SLASHSTAR_STYLE". * Supports format SystemVerilog files: pattern = "sv" and headerType = "SLASHSTAR_STYLE". ## [6.1.0] 2025-06-06 ### New features * `attrs.disk_file_created_year` can be used to replace nonexisting Git attrs like `{{attrs.git_file_created_year if attrs.git_file_created_year else attrs.disk_file_created_year }}` ## [6.0.0] 2025-01-28 ### Breaking changes Now, HawkEye uses MiniJinja as the template engine. All the `properties` configured will be passed to the template engine as the `props` value, and thus: * Previous `${property}` should be replaced with `{{ props["property"] }}`. * Previous built-in variables `hawkeye.core.filename` is now `attrs.filename`. * Previous built-in variables `hawkeye.git.fileCreatedYear` is now `attrs.git_file_created_year`. * Previous built-in variables `hawkeye.git.fileModifiedYear` is now `attrs.git_file_modified_year`. New properties: * `attrs.git_authors` is a collection of authors of the file. You can join them with `, ` to get a string by `{{ attrs.git_authors | join(", ") }}`. ### Notable changes Now, HawkEye would detect a leading BOM (Byte Order Mark) and remove it if it exists (#166). I tend to treat this as a bug fix, but it may affect the output of the header. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Guide Thanks for your help in improving the project! There are two typical contributions to this project: 1. Add support for new languages. You can refer to https://github.com/korandoru/hawkeye/pull/124 as an example. 2. Add support for new license templates. You can refer to https://github.com/korandoru/hawkeye/pull/148 as an example. You can find the core concepts with names listed below to understand the design better: * `DocumentType` defines how a file in a specific type should be handled. * `HeaderDef` defines the format of a specific header; for example, `SCRIPT_STYLE` will construct the header format for scripts like `.sh` or `.py` files. * `HeaderStyle` is the serde layer for `HeaderDef`. * `Selection` describes how to find the files to be handled. * `HeaderParser` extracts the header slice from a source file. ================================================ FILE: Cargo.toml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [workspace] members = ["cli", "fmt"] resolver = "2" [workspace.package] version = "6.5.1" edition = "2021" authors = ["tison "] readme = "README.md" license = "Apache-2.0" repository = "https://github.com/korandoru/hawkeye/" rust-version = "1.90.0" [workspace.dependencies] hawkeye-fmt = { version = "=6.5.1", path = "fmt" } build-data = { version = "0.3.0" } clap = { version = "4.5.23", features = ["derive"] } const_format = { version = "0.2.34" } exn = { version = "0.3.0" } log = { version = "0.4.22", features = ["kv_serde", "serde"] } shadow-rs = { version = "1.7.0", default-features = false } toml = { version = "1.0.1" } [workspace.metadata.release] sign-tag = true shared-version = true tag-name = "v{{version}}" pre-release-commit-message = "chore: release v{{version}}" # Config for 'cargo dist' [workspace.metadata.dist] # The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) cargo-dist-version = "0.22.1" # CI backends to support ci = "github" # The installers to generate for each app installers = ["shell"] # Target platforms to build apps for (Rust target-triple syntax) targets = [ "aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc", ] # Which actions to run on pull requests pr-run-mode = "plan" # Whether to install an updater program install-updater = false # Path that installers should place binaries in install-path = "CARGO_HOME" [workspace.metadata.dist.github-custom-runners] x86_64-unknown-linux-gnu = "ubuntu-22.04" aarch64-unknown-linux-gnu = "ubuntu-22.04-arm" x86_64-unknown-linux-musl = "ubuntu-22.04" aarch64-unknown-linux-musl = "ubuntu-22.04-arm" aarch64-apple-darwin = "macos-14" x86_64-apple-darwin = "macos-14" x86_64-pc-windows-msvc = "windows-2022" global = "ubuntu-22.04" # 'cargo dist' build with this profile [profile.dist] inherits = "release" lto = "thin" ================================================ FILE: Dockerfile ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. FROM public.ecr.aws/docker/library/rust:1.81.0-alpine3.20 as build ENV RUSTFLAGS="-C target-feature=-crt-static" WORKDIR /build COPY . . RUN apk fix && \ apk --no-cache --update add git musl-dev && \ cargo build --release --bin hawkeye FROM public.ecr.aws/docker/library/alpine:3.20.0 COPY --from=build /build/target/release/hawkeye /bin/ RUN apk fix && apk --no-cache --update add libgcc git WORKDIR /github/workspace/ ENTRYPOINT ["/bin/hawkeye"] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # HawkEye Simple license header checker and formatter, in multiple distribution forms. ## Usage You can use HawkEye in GitHub Actions or in your local machine. HawkEye provides three basic commands: ```bash # check license headers hawkeye check # format license headers (auto-fix all files that failed the check) hawkeye format # remove license headers hawkeye remove ``` You can use `-h` or `--help` to list out all config options. ### GitHub Actions The HawkEye GitHub Action enables users to run license header check by HawkEye with a config file. First of all, add a `licenserc.toml` file in the root of your project. The simplest config for projects licensed under Apache License 2.0 is as below: > [!NOTE] > The full configurations can be found in [the configuration section](#configurations). ```toml headerPath = "Apache-2.0.txt" [properties] inceptionYear = 2023 copyrightOwner = "tison " ``` You should change the copyright line according to your information. To check license headers in GitHub Actions, add a step in your GitHub workflow: ```yaml - name: Check License Header uses: korandoru/hawkeye@v6 ``` ### Docker Alpine image (~18MB): ```shell docker run -it --rm -v $(pwd):/github/workspace ghcr.io/korandoru/hawkeye check ``` ### Arch Linux > [!NOTE] > Reach out to the maintainer ([@orhun](https://github.com/orhun)) of the [package](https://archlinux.org/packages/extra/x86_64/hawkeye/) or report issues on [Arch Linux GitLab](https://gitlab.archlinux.org/archlinux/packaging/packages/hawkeye) in the case of packaging-related problems. `hawkeye` can be installed with [pacman](https://wiki.archlinux.org/title/Pacman): ```shell pacman -S hawkeye ``` ### Cargo Install The `hawkeye` executable can be installed by: ```shell cargo install hawkeye ``` ### Prebuilt Binary Instead of `cargo install`, you can install `hawkeye` as a prebuilt binary by: ```shell export VERSION=v6.0.0 curl --proto '=https' --tlsv1.2 -LsSf https://github.com/korandoru/hawkeye/releases/download/$VERSION/hawkeye-installer.sh | sh ``` It would retain more build info (output by `hawkeye -V`) than `cargo install`. ## Build This steps requires Rust toolchain. ```shell cargo build --workspace --all-features --bin --tests --examples --benches ``` Build Docker image: ```shell docker build . -t hawkeye ``` ## Configurations ### Config file ```toml # Base directory for the whole execution. # All relative paths is based on this path. # default: current working directory baseDir = "." # Inline header template. # Either inlineHeader or headerPath should be configured, and inlineHeader is prioritized. inlineHeader = "..." # Path to the header template file. # Either inlineHeader or headerPath should be configured, and inlineHeader is prioritized. # This path is resolved by the ResourceFinder. Check ResourceFinder for the concrete strategy. # The header template file is skipped on any execution. headerPath = "path/to/header.txt" # On enabled, check the license header matches exactly with whitespace. # Otherwise, strip the header in one line and check. # default: true strictCheck = true # Whether you use the default excludes. Check Default.EXCLUDES for the completed list. # To suppress part of excludes in the list, declare exact the same pattern in `includes` list. # default: true useDefaultExcludes = true # The supported patterns of includes and excludes follow gitignore pattern format, plus that: # 1. `includes` does not support `!` # 2. backslash does not escape letter # 3. whitespaces and `#` are normal since we configure line by line # See also https://git-scm.com/docs/gitignore#_pattern_format # Patterns of path to be included on execution. # default: all the files under `baseDir`. includes = ["..."] # Patterns of path to be excluded on execution. A leading bang (!) indicates an invert exclude rule. # default: empty; if useDefaultExcludes is true, append default excludes. excludes = ["..."] # Keywords that should occur in the header, case-insensitive. # default: ["copyright"] keywords = ["copyright", "..."] # Whether you use the default mapping. Check DocumentType.defaultMapping() for the completed list. # default: true useDefaultMapping = true # Paths to additional header style files. The model of user-defined header style can be found below. # default: empty additionalHeaders = ["..."] # Mapping rules (repeated). # # The key of a mapping rule is a header style type (case-insensitive). # # Available header style types consist of those defined at `HeaderType` and user-defined ones in `additionalHeaders`. # The name of header style type is case-insensitive. # # If useDefaultMapping is true, the mapping rules defined here can override the default one. [mapping.STYLE_NAME] filenames = ["..."] # e.g. "Dockerfile.native" extensions = ["..."] # e.g. "cc" # Properties to fulfill the template. # For a defined key-value pair, you can use {{props["key"]}} in the header template, which will be # substituted with the corresponding value. [properties] inceptionYear = 2023 # There are also preset attributes that can be used in the header template (no need to surround them with `props[]`).: # * 'attrs.filename' is the current file name, like: pom.xml. # * 'attrs.disk_file_created_year' # Options to configure Git features. [git] # If enabled, do not process files that are ignored by Git; possible value: ['auto', 'enable', 'disable'] # 'auto' means this feature tries to be enabled with: # * gix - if `basedir` is in a Git repository. # * ignore crate's gitignore rules - if `basedir` is not in a Git repository. # 'enable' means always enabled with gix; failed if it is impossible. # default: 'auto' ignore = 'auto' # If enabled, populate file attrs determinated by Git; possible value: ['auto', 'enable', 'disable'] # Attributes contains: # * 'attrs.git_file_created_year' # * 'attrs.git_file_modified_year' # 'auto' means this feature tries to be enabled with: # * gix - if `basedir` is in a Git repository. # 'enable' means always enabled with gix; failed if it is impossible. # default: 'disable' attrs = 'disable' ``` ### Header style file ```toml # [REQUIRED] The name of this header. [my_header_style] # The first fixed line of this header. Default to none. firstLine = "..." # The last fixed line of this header. Default to none. endLine = "..." # The characters to prepend before each license header lines. Default to empty. beforeEachLine = "..." # The characters to append after each license header lines. Default to empty. afterEachLine = "..." # Only for multi-line comments: specify if blank lines are allowed. # Default to false because most of the time, a header has some characters on each line. allowBlankLines = false # Specify whether this is a multi-line comment style or not. # # A multi-line comment style is equivalent to what we have in Java, where a first line and line will delimit # a whole multi-line comment section. # # A style that is not multi-line is usually repeating in each line the characters before and after each line # to delimit a one-line comment. # # Defaulut to true. multipleLines = true # Only for non multi-line comments: specify if some spaces should be added after the header line and before # the `afterEachLine` characters so that all the lines are aligned. # # Default to false. padLines = false # A regex to define a first line in a file that should be skipped and kept untouched, like the XML declaration # at the top of XML documents. # # Default to none. skipLinePattern = "..." # [REQUIRED] The regex used to detect the start of a header section or line. firstLineDetectionPattern = "..." # [REQUIRED] The regex used to detect the end of a header section or line. lastLineDetectionPattern = "..." ``` ## License [Apache License 2.0](LICENSE) ## History This software is originally from [license-maven-plugin](https://github.com/mathieucarbou/license-maven-plugin), with an initial motivation to bring it beyond a Maven plugin. The core abstractions like `Document`, `Header`, and `HeaderParser` are originally copied from the license-maven-plugin sources under the terms of Apache License 2.0. Later, when I started focusing on the Docker image's size and integration with Git, I found that Rust is better than Java (GraalVM Native Image) for this purpose. So, I rewrote the core logic in Rust while keeping a slim image. (The old Java implementation is achieved at the [archive-native-image](https://github.com/korandoru/hawkeye/tree/archive-native-image) branch) ================================================ FILE: action.yml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. name: HawkEye Action description: 'Check, format, or remove license headers.' author: 'tison ' branding: icon: 'code' color: 'blue' inputs: config: description: The configuration file required: false default: licenserc.toml mode: description: | Which mode License Eye should be run in. Choices are `check`, `format`, or `remove`. The default value is `check`. required: false default: check args: description: | Other arguments to be passed to the command, such as `--dry-run`, `--fail-if-unknown`, `--fail-if-updated`, etc. The default value is empty. required: false runs: using: "composite" steps: - name: Build HawkEye CLI run: cargo install --path cli shell: bash working-directory: ${{ github.action_path }} - shell: bash run: hawkeye ${{ inputs.mode }} --config ${{ inputs.config }} ${{ inputs.args }} ================================================ FILE: cli/Cargo.toml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [package] name = "hawkeye" description = "Simple license header checker and formatter, in multiple distribution forms." version.workspace = true edition.workspace = true authors.workspace = true readme.workspace = true license.workspace = true repository.workspace = true [dependencies] clap = { workspace = true } const_format = { workspace = true } exn = { workspace = true } hawkeye-fmt = { workspace = true } log = { workspace = true } logforth = { version = "0.29.1", features = ["starter-log"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = { version = "1.0.143" } shadow-rs = { workspace = true } [build-dependencies] build-data = { workspace = true } shadow-rs = { workspace = true, features = ["build"] } gix-discover = { version = "0.47.0" } ================================================ FILE: cli/build.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::collections::BTreeSet; use std::env; use std::path::Path; use build_data::format_timestamp; use build_data::get_source_time; use shadow_rs::CARGO_METADATA; use shadow_rs::CARGO_TREE; fn configure_rerun_if_head_commit_changed() { let mut current = Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf(); // skip if no valid-looking git repository could be found while let Ok((dir, _)) = gix_discover::upwards(current.as_path()) { match dir { gix_discover::repository::Path::Repository(git_dir) => { unreachable!( "build.rs should never be placed in a git bare repository: {}", git_dir.display() ); } gix_discover::repository::Path::WorkTree(work_dir) => { let git_refs_heads = work_dir.join(".git/refs/heads"); println!("cargo:rerun-if-changed={}", git_refs_heads.display()); break; } gix_discover::repository::Path::LinkedWorkTree { work_dir, .. } => { current = work_dir .parent() .expect("submodule's work_dir must have parent") .to_path_buf(); continue; } }; } } fn main() -> shadow_rs::SdResult<()> { configure_rerun_if_head_commit_changed(); println!( "cargo::rustc-env=SOURCE_TIMESTAMP={}", if let Ok(t) = get_source_time() { format_timestamp(t)? } else { "".to_string() } ); build_data::set_BUILD_TIMESTAMP(); // The "CARGO_WORKSPACE_DIR" is set manually (not by Rust itself) in Cargo config file, to // solve the problem where the "CARGO_MANIFEST_DIR" is not what we want when this repo is // made as a submodule in another repo. let src_path = env::var("CARGO_WORKSPACE_DIR").or_else(|_| env::var("CARGO_MANIFEST_DIR"))?; let out_path = env::var("OUT_DIR")?; shadow_rs::ShadowBuilder::builder() .src_path(src_path) .out_path(out_path) // exclude these two large constants that we don't need .deny_const(BTreeSet::from([CARGO_METADATA, CARGO_TREE])) .build()?; Ok(()) } ================================================ FILE: cli/src/main.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use clap::FromArgMatches; use clap::Subcommand; use logforth::filter::env_filter::EnvFilterBuilder; use crate::subcommand::SubCommand; pub mod subcommand; pub mod version; fn main() { logforth::starter_log::stderr() .filter(EnvFilterBuilder::from_default_env_or("info").build()) .apply(); let build_info = version::build_info(); let command = clap::Command::new("hawkeye") .subcommand_required(true) .about(build_info.description) .version(build_info.version) .long_version(version::version()); let command = SubCommand::augment_subcommands(command); let args = command.get_matches(); match SubCommand::from_arg_matches(&args) { Ok(cmd) => cmd.run(), Err(e) => e.exit(), } } ================================================ FILE: cli/src/subcommand.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::io::Write; use std::path::Path; use std::path::PathBuf; use clap::Args; use clap::Parser; use exn::Result; use hawkeye_fmt::document::Document; use hawkeye_fmt::error::Error; use hawkeye_fmt::header::matcher::HeaderMatcher; use hawkeye_fmt::processor::check_license_header; use hawkeye_fmt::processor::Callback; use serde::Serialize; #[derive(Parser)] pub enum SubCommand { #[clap(name = "check", about = "check license header")] Check(CommandCheck), #[clap(name = "format", about = "format license header")] Format(CommandFormat), #[clap(name = "remove", about = "remove license header")] Remove(CommandRemove), } #[derive(Args)] struct SharedOptions { #[arg(long, help = "path to the config file")] config: Option, #[arg( short = 'o', long = "output", help = "Write the output as JSON object to the specified file" )] output_file: Option, #[arg(long, help = "fail if process unknown files", default_value_t = false)] fail_if_unknown: bool, } #[derive(Args)] struct SharedEditOptions { #[arg(long, help = "whether update file in place", default_value_t = false)] dry_run: bool, #[arg( long, help = "whether to exit with non-zero code if files updated", action = clap::ArgAction::Set, default_value_t = true )] fail_if_updated: bool, } impl SubCommand { pub fn run(self) { match self { SubCommand::Check(cmd) => cmd.run(), SubCommand::Format(cmd) => cmd.run(), SubCommand::Remove(cmd) => cmd.run(), } } } #[derive(Parser)] pub struct CommandCheck { #[command(flatten)] shared: SharedOptions, #[arg( long, help = "whether to exit with non-zero code if missing headers", action = clap::ArgAction::Set, default_value_t = true )] fail_if_missing: bool, } #[derive(Serialize)] struct CheckContext { unknown: Vec, missing: Vec, } impl Callback for CheckContext { fn on_unknown(&mut self, path: &Path) { self.unknown.push(path.display().to_string()); } fn on_matched(&mut self, _: &HeaderMatcher, _: Document) -> Result<(), Error> { Ok(()) } fn on_not_matched(&mut self, _: &HeaderMatcher, document: Document) -> Result<(), Error> { self.missing.push(document.filepath.display().to_string()); Ok(()) } } impl CommandCheck { fn run(self) { let config = self.shared.config.unwrap_or_else(default_config); let mut context = CheckContext { unknown: vec![], missing: vec![], }; check_license_header(config, &mut context).unwrap(); let mut failed = check_unknown_files(&context.unknown, self.shared.fail_if_unknown); if !context.missing.is_empty() { log::error!("Found header missing in files: {:?}", context.missing); failed |= self.fail_if_missing; } if let Some(f) = self.shared.output_file { write_to_file(&f, &context); } if failed { std::process::exit(1); } log::info!("No missing header file has been found."); } } #[derive(Parser)] pub struct CommandFormat { #[command(flatten)] shared: SharedOptions, #[command(flatten)] shared_edit: SharedEditOptions, } #[derive(Serialize)] struct FormatContext { dry_run: bool, unknown: Vec, updated: Vec, } impl Callback for FormatContext { fn on_unknown(&mut self, path: &Path) { self.unknown.push(path.display().to_string()); } fn on_matched(&mut self, _: &HeaderMatcher, _: Document) -> Result<(), Error> { Ok(()) } fn on_not_matched(&mut self, header: &HeaderMatcher, mut doc: Document) -> Result<(), Error> { if doc.header_detected() { doc.remove_header(); doc.update_header(header)?; self.updated .push(format!("{}=replaced", doc.filepath.display())); } else { doc.update_header(header)?; self.updated .push(format!("{}=added", doc.filepath.display())); } if self.dry_run { let mut extension = doc.filepath.extension().unwrap_or_default().to_os_string(); extension.push(".formatted"); let copied = doc.filepath.with_extension(extension); doc.save(Some(&copied)) } else { doc.save(None) } } } impl CommandFormat { fn run(self) { let config = self.shared.config.unwrap_or_else(default_config); let mut context = FormatContext { dry_run: self.shared_edit.dry_run, unknown: vec![], updated: vec![], }; check_license_header(config, &mut context).unwrap(); let mut failed = check_unknown_files(&context.unknown, self.shared.fail_if_unknown); if !context.updated.is_empty() { log::info!( "Updated header for files (dryRun={}): {:?}", self.shared_edit.dry_run, context.updated ); failed |= self.shared_edit.fail_if_updated; } if let Some(f) = self.shared.output_file { write_to_file(&f, &context); } if failed { std::process::exit(1); } log::info!("All files have proper header."); } } #[derive(Parser)] pub struct CommandRemove { #[command(flatten)] shared: SharedOptions, #[command(flatten)] shared_edit: SharedEditOptions, } #[derive(Serialize)] struct RemoveContext { dry_run: bool, unknown: Vec, removed: Vec, } impl RemoveContext { fn remove(&mut self, doc: &mut Document) -> Result<(), Error> { if !doc.header_detected() { return Ok(()); } doc.remove_header(); self.removed .push(format!("{}=removed", doc.filepath.display())); if self.dry_run { let mut extension = doc.filepath.extension().unwrap_or_default().to_os_string(); extension.push(".removed"); let copied = doc.filepath.with_extension(extension); doc.save(Some(&copied)) } else { doc.save(None) } } } impl Callback for RemoveContext { fn on_unknown(&mut self, path: &Path) { self.unknown.push(path.display().to_string()); } fn on_matched(&mut self, _: &HeaderMatcher, mut doc: Document) -> Result<(), Error> { self.remove(&mut doc) } fn on_not_matched(&mut self, _: &HeaderMatcher, mut doc: Document) -> Result<(), Error> { self.remove(&mut doc) } } impl CommandRemove { fn run(self) { let config = self.shared.config.unwrap_or_else(default_config); let mut context = RemoveContext { dry_run: self.shared_edit.dry_run, unknown: vec![], removed: vec![], }; check_license_header(config, &mut context).unwrap(); let mut failed = check_unknown_files(&context.unknown, self.shared.fail_if_unknown); if !context.removed.is_empty() { log::info!( "Removed header for files (dryRun={}): {:?}", self.shared_edit.dry_run, context.removed ); failed |= self.shared_edit.fail_if_updated; } if let Some(f) = self.shared.output_file { write_to_file(&f, &context); } if failed { std::process::exit(1); } log::info!("No file has been removed header."); } } fn write_to_file(path: &Path, result: impl Serialize) { fn do_write_to_file(path: &Path, result: impl Serialize) -> std::io::Result<()> { let mut file = std::fs::File::create(path)?; serde_json::to_writer(&file, &result)?; file.flush() } do_write_to_file(path, result) .unwrap_or_else(|err| panic!("failed to write output to file {}: {}", path.display(), err)); } fn check_unknown_files(unknown: &[String], fail_if_unknown: bool) -> bool { if !unknown.is_empty() { if fail_if_unknown { log::error!("Processing unknown files: {unknown:?}"); return true; } else { log::warn!("Processing unknown files: {unknown:?}"); } } false } fn default_config() -> PathBuf { let candidates = [ PathBuf::from("licenserc.toml"), PathBuf::from(".licenserc.toml"), ]; for path in &candidates { if path.exists() { return path.clone(); } } panic!( "cannot find config file in any of the default locations: {:?}", candidates.iter().map(|p| p.display()).collect::>() ); } ================================================ FILE: cli/src/version.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use shadow_rs::shadow; shadow!(build); #[derive(Clone, Debug, PartialEq)] pub struct BuildInfo { pub branch: &'static str, pub commit: &'static str, pub commit_short: &'static str, pub clean: bool, pub description: &'static str, pub source_time: &'static str, pub build_time: &'static str, pub rustc: &'static str, pub target: &'static str, pub version: &'static str, } pub const fn build_info() -> BuildInfo { BuildInfo { branch: build::BRANCH, commit: build::COMMIT_HASH, commit_short: build::SHORT_COMMIT, clean: build::GIT_CLEAN, description: build::PKG_DESCRIPTION, source_time: env!("SOURCE_TIMESTAMP"), build_time: env!("BUILD_TIMESTAMP"), rustc: build::RUST_VERSION, target: build::BUILD_TARGET, version: build::PKG_VERSION, } } pub const fn version() -> &'static str { const BUILD_INFO: BuildInfo = build_info(); const_format::formatcp!( "\nversion: {}\nbranch: {}\ncommit: {}\nclean: {}\nsource_time: {}\nbuild_time: {}\nrustc: {}\ntarget: {}", BUILD_INFO.version, BUILD_INFO.branch, BUILD_INFO.commit, BUILD_INFO.clean, BUILD_INFO.source_time, BUILD_INFO.build_time, BUILD_INFO.rustc, BUILD_INFO.target, ) } ================================================ FILE: fmt/Cargo.toml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [package] name = "hawkeye-fmt" description = "The formatter library for hawkeye cli." version.workspace = true edition.workspace = true authors.workspace = true readme.workspace = true license.workspace = true repository.workspace = true [dependencies] exn = { workspace = true } gix = { version = "0.79.0", default-features = false, features = [ "blob-diff", "excludes", "status", "parallel", ] } jiff = { version = "0.2.14" } ignore = { version = "0.4.23" } log = { workspace = true } minijinja = { version = "2.15.1" } regex = { version = "1.11.1" } serde = { version = "1.0.216", features = ["derive"] } toml = { workspace = true } walkdir = { version = "2.5.0" } ================================================ FILE: fmt/src/config/mod.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::collections::HashMap; use std::collections::HashSet; use std::hash::Hash; use std::hash::Hasher; use std::path::PathBuf; use serde::de::Error; use serde::Deserialize; use serde::Deserializer; use toml::Value; use crate::default_true; #[derive(Debug, Clone, Default, Deserialize)] #[serde(default, deny_unknown_fields, rename_all = "camelCase")] pub struct Config { #[serde(default = "default_cwd")] pub base_dir: PathBuf, pub inline_header: Option, pub header_path: Option, #[serde(default = "default_true")] pub strict_check: bool, #[serde(default = "default_true")] pub use_default_excludes: bool, #[serde(default = "default_true")] pub use_default_mapping: bool, #[serde(default = "default_keywords")] pub keywords: Vec, pub includes: Vec, pub excludes: Vec, #[serde(deserialize_with = "de_properties")] pub properties: HashMap, #[serde(deserialize_with = "de_mapping")] pub mapping: HashSet, pub git: Git, pub additional_headers: Vec, } #[derive(Debug, Clone, Copy, Deserialize)] pub struct Git { pub attrs: FeatureGate, pub ignore: FeatureGate, } impl Default for Git { fn default() -> Self { Git { attrs: FeatureGate::Disable, // expensive ignore: FeatureGate::Auto, } } } #[derive(Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FeatureGate { /// Determinate whether turn on the feature. Auto, /// Force enable the feature. Enable, /// Force disable the feature. Disable, } impl FeatureGate { pub fn is_enable(&self) -> bool { match self { FeatureGate::Auto => false, FeatureGate::Enable => true, FeatureGate::Disable => false, } } pub fn is_disable(&self) -> bool { match self { FeatureGate::Auto => false, FeatureGate::Enable => false, FeatureGate::Disable => true, } } pub fn is_auto(&self) -> bool { match self { FeatureGate::Auto => true, FeatureGate::Enable => false, FeatureGate::Disable => false, } } } #[derive(Debug, Clone)] pub enum Mapping { Filename { pattern: String, header_type: String, }, Extension { pattern: String, header_type: String, }, } impl PartialEq for Mapping { fn eq(&self, other: &Self) -> bool { match (self, other) { (Mapping::Filename { pattern: p1, .. }, Mapping::Filename { pattern: p2, .. }) => { p1 == p2 } (Mapping::Extension { pattern: p1, .. }, Mapping::Extension { pattern: p2, .. }) => { p1 == p2 } _ => false, } } } impl Eq for Mapping {} impl Hash for Mapping { fn hash(&self, state: &mut H) { state.write(match self { Mapping::Filename { pattern, .. } => pattern.as_bytes(), Mapping::Extension { pattern, .. } => pattern.as_bytes(), }); } } impl Mapping { pub fn header_type(&self, filename: &str) -> Option { let filename = filename.to_lowercase(); match self { Mapping::Filename { header_type, pattern, } => { let pattern = pattern.to_lowercase(); (filename == pattern).then(|| header_type.clone()) } Mapping::Extension { header_type, pattern, } => { let pattern = format!(".{pattern}").to_lowercase(); filename.ends_with(&pattern).then(|| header_type.clone()) } } } } fn default_cwd() -> PathBuf { ".".into() } fn default_keywords() -> Vec { vec!["copyright".to_string()] } fn de_properties<'de, D>(de: D) -> Result, D::Error> where D: Deserializer<'de>, { HashMap::::deserialize(de)? .into_iter() .map(|(k, v)| { let v = match v { Value::String(v) => Ok(v), Value::Integer(v) => Ok(v.to_string()), Value::Float(v) => Ok(v.to_string()), Value::Boolean(v) => Ok(v.to_string()), Value::Datetime(v) => Ok(v.to_string()), Value::Array(_) => Err(Error::custom("array cannot be property value")), Value::Table(_) => Err(Error::custom("table cannot be property value")), }?; Ok((k, v)) }) .collect() } fn de_mapping<'de, D>(de: D) -> Result, D::Error> where D: Deserializer<'de>, { #[derive(Debug, Default, Clone, Deserialize)] #[serde(default)] struct MappingModel { extensions: Vec, filenames: Vec, } let mappings = HashMap::::deserialize(de)?; let mut set = HashSet::new(); for (header_type, model) in mappings { for pattern in model.extensions { set.insert(Mapping::Extension { pattern, header_type: header_type.clone(), }); } for pattern in model.filenames { set.insert(Mapping::Filename { pattern, header_type: header_type.clone(), }); } } Ok(set) } ================================================ FILE: fmt/src/document/defaults.toml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [ACTIONSCRIPT] pattern = "as" headerType = "SLASHSTAR_STYLE" extension = true filename = false [ADA_BODY] pattern = "adb" headerType = "DOUBLEDASHES_STYLE" extension = true filename = false [ADA_SPEC] pattern = "ads" headerType = "DOUBLEDASHES_STYLE" extension = true filename = false [ASCII_DOC] pattern = "adoc" headerType = "ASCIIDOC_STYLE" extension = true filename = false [ASP] pattern = "asp" headerType = "ASP" extension = true filename = false [ASPECTJ] pattern = "aj" headerType = "SLASHSTAR_STYLE" extension = true filename = false [ASSEMBLER] pattern = "asm" headerType = "SEMICOLON_STYLE" extension = true filename = false [C] pattern = "c" headerType = "SLASHSTAR_STYLE" extension = true filename = false [CC] pattern = "cc" headerType = "SLASHSTAR_STYLE" extension = true filename = false [CLOJURE] pattern = "clj" headerType = "SEMICOLON_STYLE" extension = true filename = false [CLOJURESCRIPT] pattern = "cljs" headerType = "SEMICOLON_STYLE" extension = true filename = false [CMAKELISTS] pattern = "CMakeLists.txt" headerType = "SCRIPT_STYLE" extension = false filename = true [COLDFUSION_COMPONENT] pattern = "cfc" headerType = "DYNASCRIPT3_STYLE" extension = true filename = false [COLDFUSION_ML] pattern = "cfm" headerType = "DYNASCRIPT3_STYLE" extension = true filename = false [CONTAINERFILE] pattern = "Containerfile" headerType = "SCRIPT_STYLE" extension = false filename = true [CPP] pattern = "cpp" headerType = "SLASHSTAR_STYLE" extension = true filename = false [CSHARP] pattern = "cs" headerType = "SLASHSTAR_STYLE" extension = true filename = false [CSS] pattern = "css" headerType = "SLASHSTAR_STYLE" extension = true filename = false [DELPHI] pattern = "pas" headerType = "BRACESSTAR_STYLE" extension = true filename = false [DOCKERFILE] pattern = "Dockerfile" headerType = "SCRIPT_STYLE" extension = false filename = true [DOXIA_APT] pattern = "apt" headerType = "DOUBLETILDE_STYLE" extension = true filename = false [DOXIA_FAQ] pattern = "fml" headerType = "XML_STYLE" extension = true filename = false [DTD] pattern = "dtd" headerType = "XML_STYLE" extension = true filename = false [EDITORCONFIG] pattern = ".editorconfig" headerType = "SCRIPT_STYLE" extension = false filename = true [EIFFEL] pattern = "e" headerType = "DOUBLEDASHES_STYLE" extension = true filename = false [ERLANG] pattern = "erl" headerType = "PERCENT3_STYLE" extension = true filename = false [ERLANG_HEADER] pattern = "hrl" headerType = "PERCENT3_STYLE" extension = true filename = false [FORTRAN] pattern = "f" headerType = "EXCLAMATION_STYLE" extension = true filename = false [FREEMARKER] pattern = "ftl" headerType = "FTL" extension = true filename = false [GO] pattern = "go" headerType = "SLASHSTAR_STYLE" extension = true filename = false [GO_MOD] pattern = "go.mod" headerType = "DOUBLESLASH_STYLE" extension = false filename = true [GRADLE] pattern = "gradle" headerType = "SLASHSTAR_STYLE" extension = true filename = false [GROOVY] pattern = "groovy" headerType = "SLASHSTAR_STYLE" extension = true filename = false [GSP] pattern = "GSP" headerType = "XML_STYLE" extension = true filename = false [H] pattern = "h" headerType = "SLASHSTAR_STYLE" extension = true filename = false [HH] pattern = "hh" headerType = "SLASHSTAR_STYLE" extension = true filename = false [HPP] pattern = "hpp" headerType = "SLASHSTAR_STYLE" extension = true filename = false [HAML] pattern = "haml" headerType = "HAML_STYLE" extension = true filename = false [HTM] pattern = "htm" headerType = "XML_STYLE" extension = true filename = false [HTML] pattern = "html" headerType = "XML_STYLE" extension = true filename = false [JAVA] pattern = "java" headerType = "SLASHSTAR_STYLE" extension = true filename = false [JAVAFX] pattern = "fx" headerType = "SLASHSTAR_STYLE" extension = true filename = false [JAVASCRIPT] pattern = "js" headerType = "SLASHSTAR_STYLE" extension = true filename = false [COMMONJS] pattern = "cjs" headerType = "SLASHSTAR_STYLE" extension = true filename = false [JSP] pattern = "jsp" headerType = "DYNASCRIPT_STYLE" extension = true filename = false [JSPX] pattern = "jspx" headerType = "XML_STYLE" extension = true filename = false [JSX] pattern = "jsx" headerType = "SLASHSTAR_STYLE" extension = true filename = false [KML] pattern = "kml" headerType = "XML_STYLE" extension = true filename = false [KOTLIN] pattern = "kt" headerType = "SLASHSTAR_STYLE" extension = true filename = false [KOTLIN_SCRIPT] pattern = "kts" headerType = "SLASHSTAR_STYLE" extension = true filename = false [LISP] pattern = "el" headerType = "EXCLAMATION3_STYLE" extension = true filename = false [LUA] pattern = "lua" headerType = "LUA" extension = true filename = false [MAKEFILE] pattern = "Makefile" headerType = "SCRIPT_STYLE" extension = true filename = true [MOONBIT] pattern = "mbt" headerType = "DOUBLESLASH_STYLE" extension = true filename = false [MUSTACHE] pattern = "mustache" headerType = "MUSTACHE_STYLE" extension = true filename = false [MVEL] pattern = "mv" headerType = "MVEL_STYLE" extension = true filename = false [MXML] pattern = "mxml" headerType = "XML_STYLE" extension = true filename = false [PERL] pattern = "pl" headerType = "SCRIPT_STYLE" extension = true filename = false [PERL_MODULE] pattern = "pm" headerType = "SCRIPT_STYLE" extension = true filename = false [PKL] pattern = "pkl" headerType = "LINE_BLOCK_STYLE" extension = true filename = false [PKL_PROJECT] pattern = "PklProject" headerType = "LINE_BLOCK_STYLE" extension = false filename = true [PHP] pattern = "php" headerType = "PHP" extension = true filename = false [POM] pattern = "pom" headerType = "XML_STYLE" extension = true filename = false [PROPERTIES] pattern = "properties" headerType = "SCRIPT_STYLE" extension = true filename = false [PROTO] pattern = "proto" headerType = "SLASHSTAR_STYLE" extension = true filename = false [PYTHON] pattern = "py" headerType = "SCRIPT_STYLE" extension = true filename = false [PYTHON_STUBS] pattern = "pyi" headerType = "SCRIPT_STYLE" extension = true filename = false [RUBY] pattern = "rb" headerType = "SCRIPT_STYLE" extension = true filename = false [RUST] pattern = "rs" headerType = "DOUBLESLASH_STYLE" extension = true filename = false [SCALA] pattern = "scala" headerType = "SLASHSTAR_STYLE" extension = true filename = false [SCAML] pattern = "scaml" headerType = "HAML_STYLE" extension = true filename = false [SCSS] pattern = "scss" headerType = "SLASHSTAR_STYLE" extension = true filename = false [SHELL] pattern = "sh" headerType = "SCRIPT_STYLE" extension = true filename = false [SPRING_FACTORIES] pattern = "spring.factories" headerType = "SCRIPT_STYLE" extension = false filename = false [SVELTE] pattern = "svelte" headerType = "XML_STYLE" extension = true filename = false [SQL] pattern = "sql" headerType = "DOUBLEDASHES_STYLE" extension = true filename = false [TAGX] pattern = "tagx" headerType = "XML_STYLE" extension = true filename = false [TEX_CLASS] pattern = "cls" headerType = "PERCENT_STYLE" extension = true filename = false [TEX_STYLE] pattern = "sty" headerType = "PERCENT_STYLE" extension = true filename = false [TEX] pattern = "tex" headerType = "PERCENT_STYLE" extension = true filename = false [TLD] pattern = "tld" headerType = "XML_STYLE" extension = true filename = false [TOML] pattern = "toml" headerType = "SCRIPT_STYLE" extension = true filename = false [TS] pattern = "ts" headerType = "SLASHSTAR_STYLE" extension = true filename = false [TSX] pattern = "tsx" headerType = "SLASHSTAR_STYLE" extension = true filename = false [VB] pattern = "bas" headerType = "HAML_STYLE" extension = true filename = false [VBA] pattern = "vba" headerType = "APOSTROPHE_STYLE" extension = true filename = false [VELOCITY] pattern = "vm" headerType = "SHARPSTAR_STYLE" extension = true filename = false [VUE] pattern = "vue" headerType = "XML_STYLE" extension = true filename = false [WINDOWS_BATCH] pattern = "bat" headerType = "BATCH" extension = true filename = false [WINDOWS_SHELL] pattern = "cmd" headerType = "BATCH" extension = true filename = false [WSDL] pattern = "wsdl" headerType = "XML_STYLE" extension = true filename = false [XHTML] pattern = "xhtml" headerType = "XML_STYLE" extension = true filename = false [XML] pattern = "xml" headerType = "XML_STYLE" extension = true filename = false [XSD] pattern = "xsd" headerType = "XML_STYLE" extension = true filename = false [XSL] pattern = "xsl" headerType = "XML_STYLE" extension = true filename = false [XSLT] pattern = "xslt" headerType = "XML_STYLE" extension = true filename = false [YAML] pattern = "yaml" headerType = "SCRIPT_STYLE" extension = true filename = false [YML] pattern = "yml" headerType = "SCRIPT_STYLE" extension = true filename = false [ZIG] pattern = "zig" headerType = "DOUBLESLASH_STYLE" extension = true filename = false [VERILOG] pattern = "v" headerType = "SLASHSTAR_STYLE" extension = true filename = false [SYSTEM_VERILOG] pattern = "sv" headerType = "SLASHSTAR_STYLE" extension = true filename = false ================================================ FILE: fmt/src/document/factory.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::collections::HashMap; use std::collections::HashSet; use std::fs; use std::path::Path; use std::path::PathBuf; use std::time::SystemTime; use exn::OptionExt; use exn::Result; use crate::config::Mapping; use crate::document::Attributes; use crate::document::Document; use crate::error::Error; use crate::git::GitFileAttrs; use crate::header::model::HeaderDef; pub struct DocumentFactory { mapping: HashSet, definitions: HashMap, properties: HashMap, keywords: Vec, git_file_attrs: HashMap, } impl DocumentFactory { pub fn new( mapping: HashSet, definitions: HashMap, properties: HashMap, keywords: Vec, git_file_attrs: HashMap, ) -> Self { Self { mapping, definitions, properties, keywords, git_file_attrs, } } pub fn create_document(&self, filepath: &Path) -> Result, Error> { let lower_file_name = filepath .file_name() .map(|n| n.to_string_lossy().to_lowercase()) .unwrap_or_default(); let header_type = self .mapping .iter() .find_map(|m| m.header_type(&lower_file_name)) .unwrap_or_else(|| "unknown".to_string()) .to_lowercase(); let header_def = self.definitions.get(&header_type).ok_or_raise(|| { Error::new(format!( "cannot create document: {}, header type {} not found", filepath.display(), header_type )) })?; let props = self.properties.clone(); let filemeta = fs::metadata(filepath).ok(); let attrs = Attributes { filename: filepath .file_name() .map(|s| s.to_string_lossy().to_string()), disk_file_created_year: filemeta .as_ref() .and_then(|m| m.created().ok()) .and_then(file_time_to_year), git_file_created_year: self .git_file_attrs .get(filepath) .and_then(|attrs| git_time_to_year(attrs.created_time)), git_file_modified_year: self .git_file_attrs .get(filepath) .and_then(|attrs| git_time_to_year(attrs.modified_time)), git_authors: self .git_file_attrs .get(filepath) .map(|attrs| attrs.authors.clone()) .unwrap_or_default(), }; Document::new( filepath.to_path_buf(), header_def.clone(), &self.keywords, props, attrs, ) } } fn file_time_to_year(time: SystemTime) -> Option { let ts = jiff::Timestamp::try_from(time).ok()?; Some(ts.to_zoned(jiff::tz::TimeZone::system()).year()) } fn git_time_to_year(t: gix::date::Time) -> Option { let offset = jiff::tz::Offset::from_seconds(t.offset).expect("valid offset"); let zoned = jiff::Timestamp::from_second(t.seconds) .expect("always valid unix time") .to_zoned(offset.to_time_zone()); Some(zoned.year()) } ================================================ FILE: fmt/src/document/mod.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::collections::BTreeSet; use std::collections::HashMap; use std::fs; use std::fs::File; use std::io::BufRead; use std::path::PathBuf; use exn::ErrorExt; use exn::Result; use exn::ResultExt; use minijinja::context; use minijinja::Environment; use serde::Deserialize; use serde::Serialize; use crate::error::Error; use crate::header::matcher::HeaderMatcher; use crate::header::model::HeaderDef; use crate::header::parser::parse_header; use crate::header::parser::FileContent; use crate::header::parser::HeaderParser; pub mod factory; pub mod model; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Attributes { pub filename: Option, pub disk_file_created_year: Option, pub git_file_created_year: Option, pub git_file_modified_year: Option, pub git_authors: BTreeSet, } #[derive(Debug)] pub struct Document { pub filepath: PathBuf, header_def: HeaderDef, props: HashMap, attrs: Attributes, parser: HeaderParser, } impl Document { pub fn new( filepath: PathBuf, header_def: HeaderDef, keywords: &[String], props: HashMap, attrs: Attributes, ) -> Result, Error> { match FileContent::new(&filepath) { Ok(content) => Ok(Some(Self { parser: parse_header(content, &header_def, keywords), filepath, header_def, props, attrs, })), Err(err) => { if matches!(err.kind(), std::io::ErrorKind::InvalidData) { log::debug!("skip non-textual file: {}", filepath.display()); Ok(None) } else { Err(err.raise().raise(Error::new(format!( "cannot create document: {}", filepath.display() )))) } } } } pub fn is_unsupported(&self) -> bool { self.header_def.name.eq_ignore_ascii_case("unknown") } /// Detected but not necessarily a valid header pub fn header_detected(&self) -> bool { self.parser.end_pos.is_some() } /// Detected and valid header pub fn header_matched( &self, header: &HeaderMatcher, strict_check: bool, ) -> Result { if strict_check { let file_header = { let mut lines = self.read_file_first_lines(header)?.join("\n"); lines.push_str("\n\n"); lines.replace(" *\r?\n", "\n") }; let expected_header = { let raw_header = header.build_for_definition(&self.header_def); let resolved_header = self.merge_properties(&raw_header)?; resolved_header.replace(" *\r?\n", "\n") }; Ok(file_header.contains(expected_header.as_str())) } else { let file_header = self.read_file_header_on_one_line(header)?; let expected_header = self.merge_properties(header.header_content_one_line())?; Ok(file_header.contains(expected_header.as_str())) } } #[track_caller] fn read_file_first_lines(&self, header: &HeaderMatcher) -> Result, Error> { let make_error = || Error::new("cannot read file first line"); let file = File::open(&self.filepath).or_raise(make_error)?; std::io::BufReader::new(file) .lines() .take(header.header_content_lines_count() + 10) .collect::>>() .or_raise(make_error) } #[track_caller] fn read_file_header_on_one_line(&self, header: &HeaderMatcher) -> Result { let first_lines = self.read_file_first_lines(header)?; let file_header = first_lines .join("") .trim() .replace(self.header_def.first_line.trim(), "") .replace(self.header_def.end_line.trim(), "") .replace(self.header_def.before_each_line.trim(), "") .replace(self.header_def.after_each_line.trim(), "") .split_whitespace() .collect(); Ok(file_header) } pub fn update_header(&mut self, header: &HeaderMatcher) -> Result<(), Error> { let header_str = header.build_for_definition(&self.header_def); let header_str = self.merge_properties(&header_str)?; let begin_pos = self.parser.begin_pos; self.parser .file_content .insert(begin_pos, header_str.as_str()); Ok(()) } pub fn remove_header(&mut self) { if let Some(end_pos) = self.parser.end_pos { self.parser .file_content .delete(self.parser.begin_pos, end_pos); } } pub fn save(&mut self, filepath: Option<&PathBuf>) -> Result<(), Error> { let filepath = filepath.unwrap_or(&self.filepath); fs::write(filepath, self.parser.file_content.content()) .or_raise(|| Error::new(format!("cannot save document {}", filepath.display()))) } pub(crate) fn merge_properties(&self, s: &str) -> Result { let mut env = Environment::new(); env.add_template("template", s) .or_raise(|| Error::new("malformed template"))?; let tmpl = env.get_template("template").expect("template must exist"); let mut result = tmpl .render(context! { props => &self.props, attrs => &self.attrs, }) .or_raise(|| Error::new("cannot render template"))?; result.push('\n'); Ok(result) } } ================================================ FILE: fmt/src/document/model.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::collections::HashMap; use serde::Deserialize; use serde::Serialize; use crate::config::Mapping; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct DocumentType { pub pattern: String, pub header_type: String, pub extension: bool, pub filename: bool, } pub fn default_mapping() -> Vec { let defaults = include_str!("defaults.toml"); let mapping: HashMap = toml::from_str(defaults).expect("default mapping must be valid"); mapping .into_values() .flat_map(|doctype| { let mut ms = vec![]; if doctype.extension { ms.push(Mapping::Extension { pattern: doctype.pattern.clone(), header_type: doctype.header_type.clone(), }) } if doctype.filename { ms.push(Mapping::Filename { pattern: doctype.pattern, header_type: doctype.header_type, }) } ms }) .collect() } ================================================ FILE: fmt/src/error.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. #[derive(Debug)] pub struct Error { message: String, } impl Error { pub fn new(message: impl Into) -> Self { Self { message: message.into(), } } } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.message) } } impl std::error::Error for Error {} ================================================ FILE: fmt/src/git.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::collections::hash_map::Entry; use std::collections::BTreeSet; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; use exn::bail; use exn::ErrorExt; use exn::Result; use exn::ResultExt; use gix::diff::tree_with_rewrites::Change; use gix::status::Item; use gix::Repository; use walkdir::WalkDir; use crate::config; use crate::config::FeatureGate; use crate::error::Error; #[derive(Debug, Clone)] pub struct GitContext { pub repo: Option, pub config: config::Git, } pub fn discover(basedir: &Path, config: config::Git) -> Result { let feature = resolve_features(&config); if feature.is_disable() { return Ok(GitContext { repo: None, config }); } match gix::discover(basedir) { Ok(repo) => match repo.worktree() { None => { let message = "bare repository detected"; if feature.is_auto() { log::info!(config:?; "git config is resolved to disabled; {message}"); Ok(GitContext { repo: None, config }) } else { bail!(Error::new(format!("invalid config: {message}"))) } } Some(_) => { log::info!("git config is resolved to enabled"); Ok(GitContext { repo: Some(repo), config, }) } }, Err(err) => { if feature.is_auto() { log::info!(err:?, config:?; "git config is resolved to disabled"); Ok(GitContext { repo: None, config }) } else { Err(err .raise() .raise(Error::new("cannot discover git repository with gix"))) } } } } fn resolve_features(config: &config::Git) -> FeatureGate { let features = [config.attrs, config.ignore]; for feature in features.iter() { if feature.is_enable() { return FeatureGate::Enable; } } for feature in features.iter() { if feature.is_auto() { return FeatureGate::Auto; } } FeatureGate::Disable } #[derive(Debug)] pub struct GitFileAttrs { pub created_time: gix::date::Time, pub modified_time: gix::date::Time, pub authors: BTreeSet, } pub fn resolve_file_attrs( git_context: GitContext, ) -> Result, Error> { let mut attrs = HashMap::new(); if git_context.config.attrs.is_disable() { return Ok(attrs); } let repo = match git_context.repo { Some(repo) => repo, None => return Ok(attrs), }; let current_username = match repo.committer() { Some(Ok(username)) => username.name.to_string(), _ => "".to_string(), }; let worktree = repo.worktree().expect("worktree cannot be absent"); let workdir = repo.workdir().expect("workdir cannot be absent"); let workdir = workdir.canonicalize().or_raise(|| { Error::new(format!( "cannot resolve absolute path: {}", workdir.display() )) })?; let mut excludes = worktree .excludes(None) .or_raise(|| Error::new("cannot create gix exclude stack"))?; let mut update_attrs = |rela_path: &Path, time: gix::date::Time, author: &str| { let filepath = workdir.join(rela_path); match attrs.entry(filepath) { Entry::Occupied(mut ent) => { let attrs: &mut GitFileAttrs = ent.get_mut(); attrs.created_time = time.min(attrs.created_time); attrs.modified_time = time.max(attrs.modified_time); attrs.authors.insert(author.to_string()); } Entry::Vacant(ent) => { ent.insert(GitFileAttrs { created_time: time, modified_time: time, authors: { let mut authors = BTreeSet::new(); authors.insert(author.to_string()); authors }, }); } } }; let mut process_changes = |changes: Vec, time: gix::date::Time, author: &str| { for change in changes { match change { Change::Addition { location, .. } | Change::Modification { location, .. } => { update_attrs(&gix::path::from_bstring(location), time, author); } Change::Deletion { .. } => continue, // skip deletion Change::Rewrite { .. } => unreachable!("rewrite has been disabled"), } } }; let option = { let mut option = gix::diff::Options::default(); option.track_path(); option }; let make_error = || Error::new("cannot resolve git file attributes"); let head = repo.head_commit().or_raise(make_error)?; let mut next_commit = head.clone(); for info in head.ancestors().all().or_raise(make_error)? { let info = info.or_raise(make_error)?; let this_commit = info.object().or_raise(make_error)?; let time = next_commit.time().or_raise(make_error)?; let author = next_commit.author().or_raise(make_error)?.name.to_string(); let this_tree = this_commit.tree().or_raise(make_error)?; let next_tree = next_commit.tree().or_raise(make_error)?; let changes = repo .diff_tree_to_tree(Some(&this_tree), Some(&next_tree), Some(option)) .or_raise(make_error)?; process_changes(changes, time, &author); next_commit = this_commit; } // process the root commit let time = next_commit.time().or_raise(make_error)?; let author = next_commit.author().or_raise(make_error)?.name.to_string(); let next_tree = next_commit.tree().or_raise(make_error)?; let changes = repo .diff_tree_to_tree(None, Some(&next_tree), Some(option)) .or_raise(make_error)?; process_changes(changes, time, &author); // process dirty working tree let index = repo.index_or_empty().or_raise(make_error)?; let status_platform = repo.status(gix::progress::Discard).or_raise(make_error)?; let status_iter = status_platform.into_iter(None).or_raise(make_error)?; let now = gix::date::Time::now_local_or_utc(); for item in status_iter { match item.or_raise(|| Error::new("failed to check git status item"))? { Item::IndexWorktree(item) => match item { gix::status::index_worktree::Item::Modification { rela_path, .. } => { let rela_path = gix::path::from_bstring(rela_path); update_attrs(&rela_path, now, current_username.as_str()); } gix::status::index_worktree::Item::DirectoryContents { entry, .. } => { if entry.disk_kind.is_some_and(|k| k.is_dir()) { let dirpath = workdir.join(gix::path::from_bstr(&entry.rela_path)); let mut it = WalkDir::new(dirpath).follow_links(false).into_iter(); while let Some(entry) = it.next() { let entry = entry.or_raise(|| Error::new("cannot traverse directory"))?; let path = entry.path(); let file_type = entry.file_type(); if !file_type.is_file() && !file_type.is_dir() { log::debug!(file_type:?; "skip file: {path:?}"); continue; } let rela_path = path .strip_prefix(&workdir) .expect("git repository encloses iteration"); let mode = Some(if file_type.is_dir() { gix::index::entry::Mode::DIR } else { gix::index::entry::Mode::FILE }); let platform = excludes .at_path(rela_path, mode) .or_raise(|| Error::new("cannot check gix exclude"))?; if file_type.is_dir() { if platform.is_excluded() { let rela = gix::path::try_into_bstr(rela_path).or_raise(|| { Error::new("cannot convert path to git path") })?; if !index.path_is_directory(rela.as_ref()) { log::debug!(path:?, rela_path:?; "skip git ignored directory"); it.skip_current_dir(); continue; } } } else if file_type.is_file() { if platform.is_excluded() { let rela = gix::path::try_into_bstr(rela_path).or_raise(|| { Error::new("cannot convert path to git path") })?; if index.entry_by_path(rela.as_ref()).is_none() { log::debug!(path:?, rela_path:?; "skip git ignored file"); continue; } } update_attrs(rela_path, now, current_username.as_str()); } } } else { let rela_path = gix::path::from_bstring(entry.rela_path); update_attrs(&rela_path, now, current_username.as_str()); } } gix::status::index_worktree::Item::Rewrite { .. } => { unreachable!("rewrite has been disabled") } }, Item::TreeIndex(item) => { let rela_path = gix::path::from_bstr(item.location()); update_attrs(&rela_path, now, current_username.as_str()); } } } Ok(attrs) } ================================================ FILE: fmt/src/header/defaults.toml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [SHARPSTAR_STYLE] firstLine = '#*' endLine = ' *#' beforeEachLine = ' * ' afterEachLine = '' allowBlankLines = false multipleLines = true padLines = false firstLineDetectionPattern = '(\s|\t)*#\*.*$' lastLineDetectionPattern = '.*\*#(\s|\t)*$' [PERCENT_STYLE] firstLine = '' endLine = "\n" beforeEachLine = '% ' afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false firstLineDetectionPattern = '^% .*$' lastLineDetectionPattern = '^% .*$' [BRACESSTAR_STYLE] firstLine = '{*' endLine = ' *}' beforeEachLine = ' * ' afterEachLine = '' allowBlankLines = false multipleLines = true padLines = false firstLineDetectionPattern = '(\s|\t)*\{\*.*$' lastLineDetectionPattern = '.*\*\}(\s|\t)*$' [EXCLAMATION_STYLE] firstLine = '!' endLine = "!\n" beforeEachLine = '! ' afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false firstLineDetectionPattern = '!.*$' lastLineDetectionPattern = '!.*$' [MVEL_STYLE] firstLine = '@comment{' endLine = '}' beforeEachLine = ' ' afterEachLine = '' allowBlankLines = true multipleLines = true padLines = false firstLineDetectionPattern = '@comment\{$' lastLineDetectionPattern = '\}$' [APOSTROPHE_STYLE] firstLine = "'" endLine = "'\n" beforeEachLine = "' " afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false firstLineDetectionPattern = "'.*$" lastLineDetectionPattern = "'.*$" [TRIPLESLASH_STYLE] firstLine = '///' endLine = "///\n" beforeEachLine = '/// ' afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false firstLineDetectionPattern = '///.*$' lastLineDetectionPattern = '///.*$' [PHP] firstLine = '/*' endLine = ' */' beforeEachLine = ' * ' afterEachLine = '' allowBlankLines = false multipleLines = true padLines = false skipLinePattern = '^<\?php.*$' firstLineDetectionPattern = '(\s|\t)*/\*.*$' lastLineDetectionPattern = '.*\*/(\s|\t)*$' [LUA] firstLine = "--[[\n" endLine = "\n]]" beforeEachLine = ' ' afterEachLine = '' allowBlankLines = true multipleLines = true padLines = false firstLineDetectionPattern = '--\[\[$' lastLineDetectionPattern = '\]\]$' [SCALA_STYLE] firstLine = '/**' endLine = ' */' beforeEachLine = ' * ' afterEachLine = '' allowBlankLines = false multipleLines = true padLines = false firstLineDetectionPattern = '(\s|\t)*/\*.*$' lastLineDetectionPattern = '.*\*/(\s|\t)*$' [BATCH] firstLine = '@REM' endLine = "@REM\n" beforeEachLine = '@REM ' afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false firstLineDetectionPattern = '@REM.*$' lastLineDetectionPattern = '@REM.*$' [MUSTACHE_STYLE] firstLine = '{{!' endLine = '}}' beforeEachLine = ' ' afterEachLine = '' allowBlankLines = false multipleLines = true padLines = false firstLineDetectionPattern = '\{\{\!.*$' lastLineDetectionPattern = '\}\}.*$' [HAML_STYLE] firstLine = '-#' endLine = "-#\n" beforeEachLine = '-# ' afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false skipLinePattern = '^-#!.*$' firstLineDetectionPattern = '-#.*$' lastLineDetectionPattern = '-#.*$' [PERCENT3_STYLE] firstLine = '%%%' endLine = "%%%\n" beforeEachLine = '%%% ' afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false firstLineDetectionPattern = '%%%.*$' lastLineDetectionPattern = '%%%.*$' [EXCLAMATION3_STYLE] firstLine = '!!!' endLine = "!!!\n" beforeEachLine = '!!! ' afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false firstLineDetectionPattern = '!!!.*$' lastLineDetectionPattern = '!!!.*$' [ASP] firstLine = '<%' endLine = '%>' beforeEachLine = "' " afterEachLine = '' allowBlankLines = true multipleLines = true padLines = false firstLineDetectionPattern = '(\s|\t)*<%( .*)?$' lastLineDetectionPattern = '.*%>(\s|\t)*$' [SCRIPT_STYLE] firstLine = '' endLine = "\n" beforeEachLine = '# ' afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false skipLinePattern = '^#!.*$' firstLineDetectionPattern = '#.*$' lastLineDetectionPattern = '#.*$' [JAVAPKG_STYLE] firstLine = "\n/*-" endLine = ' */' beforeEachLine = ' * ' afterEachLine = '' allowBlankLines = false multipleLines = true padLines = false skipLinePattern = '^package [a-z_]+(\.[a-z_][a-z0-9_]*)*;$' firstLineDetectionPattern = '(\s|\t)*/\*.*$' lastLineDetectionPattern = '.*\*/(\s|\t)*$' [DOUBLESLASH_STYLE] firstLine = '' endLine = "\n" beforeEachLine = '// ' afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false firstLineDetectionPattern = '//.*$' lastLineDetectionPattern = '//.*$' [XML_STYLE] firstLine = "" beforeEachLine = ' ' afterEachLine = '' allowBlankLines = true multipleLines = true padLines = false skipLinePattern = '^<\?xml.*>$' firstLineDetectionPattern = '(\s|\t)*(\s|\t)*$' [ASCIIDOC_STYLE] firstLine = '////' endLine = "////\n" beforeEachLine = ' // ' afterEachLine = '' allowBlankLines = false multipleLines = true padLines = false firstLineDetectionPattern = '^////$' lastLineDetectionPattern = '^////$' [SEMICOLON_STYLE] firstLine = ';' endLine = ";\n" beforeEachLine = '; ' afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false firstLineDetectionPattern = ';.*$' lastLineDetectionPattern = ';.*$' [DOUBLETILDE_STYLE] firstLine = '~~' endLine = "~~\n" beforeEachLine = '~~ ' afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false firstLineDetectionPattern = '~~.*$' lastLineDetectionPattern = '~~.*$' [SLASHSTAR_STYLE] firstLine = '/*' endLine = " */\n" beforeEachLine = ' * ' afterEachLine = '' allowBlankLines = false multipleLines = true padLines = false firstLineDetectionPattern = '(\s|\t)*/\*.*$' lastLineDetectionPattern = '.*\*/(\s|\t)*$' [FTL_ALT] firstLine = "[#--\n" endLine = "\n--]" beforeEachLine = ' ' afterEachLine = '' allowBlankLines = true multipleLines = true padLines = false skipLinePattern = '\[#ftl(\s.*)?\]' firstLineDetectionPattern = '(\s|\t)*\[#--.*$' lastLineDetectionPattern = '.*--\](\s|\t)*$' [DOUBLEDASHES_STYLE] firstLine = '--' endLine = "--\n" beforeEachLine = '-- ' afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false firstLineDetectionPattern = '--.*$' lastLineDetectionPattern = '--.*$' [DYNASCRIPT3_STYLE] firstLine = "" beforeEachLine = ' ' afterEachLine = '' allowBlankLines = true multipleLines = true padLines = false firstLineDetectionPattern = '(\s|\t)*(\s|\t)*$' [SINGLE_LINE_DOUBLESLASH_STYLE] firstLine = '' endLine = '' beforeEachLine = '// ' afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false firstLineDetectionPattern = '//.*$' lastLineDetectionPattern = '//.*$' [XML_PER_LINE] firstLine = "\n" endLine = "\n" beforeEachLine = '' allowBlankLines = false multipleLines = false padLines = true skipLinePattern = '^<\?xml.*>$' firstLineDetectionPattern = '(\s|\t)*(\s|\t)*$' [UNKNOWN] firstLine = '' endLine = '' beforeEachLine = '' afterEachLine = '' allowBlankLines = false multipleLines = false padLines = false firstLineDetectionPattern = '' lastLineDetectionPattern = '' [JAVADOC_STYLE] firstLine = '/**' endLine = ' */' beforeEachLine = ' * ' afterEachLine = '' allowBlankLines = false multipleLines = true padLines = false firstLineDetectionPattern = '(\s|\t)*/\*.*$' lastLineDetectionPattern = '.*\*/(\s|\t)*$' [DYNASCRIPT_STYLE] firstLine = "<%--\n" endLine = "\n--%>" beforeEachLine = ' ' afterEachLine = '' allowBlankLines = true multipleLines = true padLines = false firstLineDetectionPattern = '(\s|\t)*<%--.*$' lastLineDetectionPattern = '.*--%>(\s|\t)*$' [FTL] firstLine = "<#--\n" endLine = "\n-->" beforeEachLine = ' ' afterEachLine = '' allowBlankLines = true multipleLines = true padLines = false firstLineDetectionPattern = '(\s|\t)*<#--.*$' lastLineDetectionPattern = '.*-->(\s|\t)*$' [LINE_BLOCK_STYLE] firstLine = '//===----------------------------------------------------------------------===//' endLine = "//===----------------------------------------------------------------------===//\n" beforeEachLine = '// ' afterEachLine = '' allowBlankLines = false multipleLines = true padLines = false firstLineDetectionPattern = '//\s?===' lastLineDetectionPattern = '//\s?===' ================================================ FILE: fmt/src/header/matcher.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::fmt::Display; use std::fmt::Formatter; use crate::header::model::HeaderDef; #[derive(Debug)] pub struct HeaderMatcher { header_content: String, header_content_one_line: String, header_content_lines: Vec, max_length: usize, } impl HeaderMatcher { pub fn new(header_content: String) -> Self { let header_content_one_line = header_content.split_whitespace().collect(); let header_content_lines = header_content .lines() .map(ToString::to_string) .collect::>(); let max_length = header_content_lines .iter() .map(|l| l.len()) .max() .unwrap_or(0); Self { header_content, header_content_one_line, header_content_lines, max_length, } } pub fn build_for_definition(&self, def: &HeaderDef) -> String { let eol = "\n"; let mut result = String::new(); if !def.first_line.is_empty() { result.push_str(&def.first_line); if def.first_line != eol { result.push_str(eol); } } for line in &self.header_content_lines { let before = &def.before_each_line; let after = &def.after_each_line; let this_line = if def.pad_lines { let max_length = self.max_length; format!("{before}{line: usize { self.header_content_lines.len() } pub fn header_content_one_line(&self) -> &str { &self.header_content_one_line } } impl Display for HeaderMatcher { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.write_str(&self.header_content) } } ================================================ FILE: fmt/src/header/mod.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. pub mod matcher; pub mod model; pub mod parser; ================================================ FILE: fmt/src/header/model.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::collections::HashMap; use exn::Result; use exn::ResultExt; use regex::Regex; use serde::Deserialize; use serde::Serialize; use crate::default_true; use crate::error::Error; #[derive(Debug, Clone)] pub struct HeaderDef { pub name: String, pub first_line: String, pub end_line: String, pub before_each_line: String, pub after_each_line: String, pub allow_blank_lines: bool, pub multiple_lines: bool, pub pad_lines: bool, pub skip_line_pattern: Option, pub first_line_detection_pattern: Option, pub last_line_detection_pattern: Option, } impl HeaderDef { /// Tells if the given content line must be skipped according to this header definition. The /// header is outputted after any skipped line if any pattern defined on this point or on the /// first line if not pattern defined. pub fn is_skip_line(&self, line: &str) -> bool { self.skip_line_pattern .as_ref() .is_some_and(|pattern| pattern.is_match(line)) } /// Tells if the given content line is the first line of a possible header of this definition /// kind. pub fn is_first_header_line(&self, line: &str) -> bool { self.first_line_detection_pattern .as_ref() .is_some_and(|p| p.is_match(line)) } /// Tells if the given content line is the last line of a possible header of this definition /// kind. pub fn is_last_header_line(&self, line: &str) -> bool { self.last_line_detection_pattern .as_ref() .is_some_and(|p| p.is_match(line)) } } pub fn default_headers() -> HashMap { let defaults = include_str!("defaults.toml"); deserialize_header_definitions(defaults.to_string()).unwrap() } pub fn deserialize_header_definitions(value: String) -> Result, Error> { let header_styles = toml::from_str::>(&value) .or_raise(|| Error::new("failed to parse header definitions"))?; let headers = header_styles .into_iter() .map(|(name, style)| { let name = name.to_lowercase(); assert!( !style.allow_blank_lines || style.multiple_lines, "Header style {name} allowing blank lines must be of multi-line header style" ); let def = HeaderDef { name: name.clone(), first_line: style.first_line, end_line: style.end_line, before_each_line: style.before_each_line, after_each_line: style.after_each_line, allow_blank_lines: style.allow_blank_lines, multiple_lines: style.multiple_lines, pad_lines: style.pad_lines, skip_line_pattern: style .skip_line_pattern .map(|pattern| { Regex::new(&pattern).or_raise(|| { Error::new(format!("malformed skip_line_pattern: {pattern}")) }) }) .transpose()?, first_line_detection_pattern: style .first_line_detection_pattern .map(|pattern| { Regex::new(&pattern).or_raise(|| { Error::new(format!("malformed first_line_detection_pattern: {pattern}")) }) }) .transpose()?, last_line_detection_pattern: style .last_line_detection_pattern .map(|pattern| { Regex::new(&pattern).or_raise(|| { Error::new(format!("malformed last_line_detection_pattern: {pattern}")) }) }) .transpose()?, }; Ok((name, def)) }) .collect::, Error>>()?; Ok(headers) } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct HeaderStyle { /// The first fixed line of this header. pub first_line: String, /// The last fixed line of this header. pub end_line: String, /// The characters to prepend before each license header lines. Default to empty. pub before_each_line: String, /// The characters to append after each license header lines. Default to empty. pub after_each_line: String, /// Only for multi-line comments: specify if blank lines are allowed. /// Default to false because most of the time, a header has some characters on each line. pub allow_blank_lines: bool, /// Specify whether this is a multi-line comment style or not. /// /// A multi-line comment style is equivalent to what we have in Java, where a first line and /// line will delimit a whole multi-line comment section. /// /// A style that is not multi-line is usually repeating in each line the characters before and /// after each line to delimit a one-line comment. #[serde(default = "default_true")] pub multiple_lines: bool, /// Only for non multi-line comments: specify if some spaces should be added after the header /// line and before the {@link #afterEachLine} characters so that all the lines are aligned. /// Default to false. pub pad_lines: bool, /// A regex to define a first line in a file that should be skipped and kept untouched, like /// the XML declaration at the top of XML documents. Default to none. pub skip_line_pattern: Option, /// The regex used to detect the start of a header section or line. pub first_line_detection_pattern: Option, /// The regex used to detect the end of a header section or line. pub last_line_detection_pattern: Option, } ================================================ FILE: fmt/src/header/parser.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::fmt::Display; use std::fmt::Formatter; use std::fs::File; use std::io::BufRead; use std::io::BufReader; use std::path::Path; use crate::header::model::HeaderDef; #[derive(Debug)] pub struct HeaderParser { pub begin_pos: usize, /// Some if header exists; None if header does not exist. pub end_pos: Option, pub file_content: FileContent, } pub fn parse_header( mut file_content: FileContent, header_def: &HeaderDef, keywords: &[String], ) -> HeaderParser { let mut line = file_content.next_line(); // 1. find begin position let begin_pos = find_first_position(&mut line, &mut file_content, header_def); // 2. has header let existing_header = existing_header(&mut line, &mut file_content, header_def, keywords); // 3. find end position let end_pos = if existing_header { // we check if there is a header, if the next line is the blank line of the header let mut end = file_content.pos; line = file_content.next_line(); if begin_pos == 0 { while line.as_ref().map(|l| l.trim().is_empty()).unwrap_or(false) { end = file_content.pos; line = file_content.next_line(); } } if header_def.end_line.ends_with('\n') && line.as_ref().map(|l| l.trim().is_empty()).unwrap_or(false) { end = file_content.pos; } Some(end) } else { None }; HeaderParser { begin_pos, end_pos, file_content, } } fn find_first_position( line: &mut Option, file_content: &mut FileContent, header_def: &HeaderDef, ) -> usize { const UTF8_BOM: [u8; 3] = [0xEF, 0xBB, 0xBF]; let mut begin_pos = 0; if let Some(l) = line.as_ref() { // skip UTF-8 BOM if exists if l.as_bytes().starts_with(&UTF8_BOM) { log::debug!("Detected UTF-8 BOM for {file_content}; skip"); begin_pos = 3; file_content.reset_to(3); } } if header_def.skip_line_pattern.is_some() { // the format expect to find lines to be skipped while line .as_ref() .map(|l| !header_def.is_skip_line(l)) .unwrap_or(false) { begin_pos = file_content.pos; *line = file_content.next_line(); } // at least we have found the line to skip, or we are the end of the file // this time we are going to skip next lines if they match the skip pattern while line .as_ref() .map(|l| header_def.is_skip_line(l)) .unwrap_or(false) { begin_pos = file_content.pos; *line = file_content.next_line(); } // After skipping everything we are at the end of the file // Header has to be at the file beginning if line.is_none() { begin_pos = 0; file_content.reset(); *line = file_content.next_line(); // recheck for UTF-8 BOM if let Some(l) = line.as_ref() { if l.as_bytes().starts_with(&UTF8_BOM) { begin_pos = 3; file_content.reset_to(3); } } } } begin_pos } fn existing_header( line: &mut Option, file_content: &mut FileContent, header_def: &HeaderDef, keywords: &[String], ) -> bool { // skip blank lines while line.as_ref().map(|l| l.trim().is_empty()).unwrap_or(false) { *line = file_content.next_line(); } // check if there is already a header let l = match line.as_ref() { Some(l) if header_def.is_first_header_line(l) => l, _ => return false, }; let mut got_header = false; let mut in_place_header = String::new(); in_place_header.push_str(&l.to_lowercase()); *line = file_content.next_line(); // skip blank lines before header text if header_def.allow_blank_lines { while line.as_ref().map(|l| l.trim().is_empty()).unwrap_or(false) { *line = file_content.next_line(); } } // first header detected line & potential blank lines have been detected // following lines should be header lines if let Some(l) = line.as_ref() { let before = { let mut before = header_def.before_each_line.trim_end(); if before.is_empty() && !header_def.multiple_lines { before = header_def.before_each_line.as_str(); } before }; let found_end = { let mut found_end = false; if (header_def.multiple_lines && header_def.is_last_header_line(l)) || l.trim().is_empty() { in_place_header.push_str(&l.to_lowercase()); found_end = true; } else { loop { match line.as_ref() { Some(l) if l.starts_with(before) => { in_place_header.push_str(&l.to_lowercase()); if header_def.multiple_lines && header_def.is_last_header_line(l) { found_end = true; break; } } _ => break, } *line = file_content.next_line(); } if line.as_ref().map(|l| l.trim().is_empty()).unwrap_or(true) { found_end = true; } } found_end }; // skip blank lines after header text if header_def.multiple_lines && header_def.allow_blank_lines && !found_end { loop { if !line.as_ref().map(|l| l.trim().is_empty()).unwrap_or(false) { break; } *line = file_content.next_line(); } file_content.rewind(); } else if !header_def.multiple_lines && !found_end { file_content.rewind(); } if !header_def.multiple_lines { // keep track of the position for headers where the end line is the same as the // before each line let pos = file_content.pos; // check if the line is the end line while line .as_ref() .map(|l| { !header_def.is_last_header_line(l) && (header_def.allow_blank_lines || !l.trim().is_empty()) && l.starts_with(before) }) .unwrap_or(false) { *line = file_content.next_line(); } if line.is_none() { file_content.reset_to(pos); } } else if line.is_some() { // we could end up there if we still have some lines, but not matching "before". // This can be the last line in a multi line header let pos = file_content.pos; *line = file_content.next_line(); if line .as_ref() .map(|l| !header_def.is_last_header_line(l)) .unwrap_or(true) { file_content.reset_to(pos); } } got_header = true; for keyword in keywords { if !in_place_header.contains(keyword) { got_header = false; break; } } } // else - we detected previously a one line comment block that matches the header // detection it is not a header it is a comment got_header } #[derive(Debug)] pub struct FileContent { pos: usize, old_pos: usize, content: String, filepath: String, } impl Display for FileContent { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.write_str(&self.filepath) } } impl FileContent { pub fn new(file: &Path) -> std::io::Result { Ok(Self { pos: 0, old_pos: 0, content: { let mut content = String::new(); let mut reader = File::open(file).map(BufReader::new)?; let mut buf = String::new(); let mut n = reader.read_line(&mut buf)?; while n > 0 { if buf.ends_with('\n') { buf.pop(); if buf.ends_with('\r') { buf.pop(); } content.push_str(&buf); content.push('\n'); } else { content.push_str(&buf); } buf.clear(); n = reader.read_line(&mut buf)?; } content }, filepath: file.to_string_lossy().to_string(), }) } pub fn reset_to(&mut self, pos: usize) { self.old_pos = pos; self.pos = pos; } pub fn reset(&mut self) { self.reset_to(0); } pub fn rewind(&mut self) { self.pos = self.old_pos; } pub fn end_reached(&self) -> bool { self.pos >= self.content.len() } pub fn next_line(&mut self) -> Option { if self.end_reached() { return None; } let lf = self.content[self.pos..].find('\n').map(|i| i + self.pos); let eol = lf.unwrap_or(self.content.len()); let result = self.content[self.pos..eol].to_string(); self.old_pos = self.pos; self.pos = if let Some(lf) = lf { lf + 1 } else { self.content.len() }; Some(result) } pub fn content(&self) -> String { self.content.clone() } pub fn insert(&mut self, index: usize, s: &str) { self.content.insert_str(index, s); } pub fn delete(&mut self, start: usize, end: usize) { self.content.drain(start..end); } } ================================================ FILE: fmt/src/lib.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. pub mod config; pub mod document; pub mod error; pub mod git; pub mod header; pub mod license; pub mod processor; pub mod selection; const fn default_true() -> bool { true } ================================================ FILE: fmt/src/license/Apache-2.0-ASF.txt ================================================ Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: fmt/src/license/Apache-2.0.txt ================================================ Copyright {{props["inceptionYear"]}} {{props["copyrightOwner"]}} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: fmt/src/license/Elastic-2.0.txt ================================================ Copyright {{props["inceptionYear"]}} {{props["copyrightOwner"]}} Licensed under the Elastic License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.elastic.co/licensing/elastic-license Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: fmt/src/license/mod.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. #[derive(Debug, Clone)] pub struct HeaderSource { pub content: String, } macro_rules! match_bundled_headers { ($name:expr, $($file:expr),*) => { match $name { $( $file => Some(HeaderSource { content: include_str!($file).to_string() }), )* _ => None, } } } pub(crate) fn bundled_headers(name: &str) -> Option { match_bundled_headers!( name, "Apache-2.0.txt", "Apache-2.0-ASF.txt", "Elastic-2.0.txt" ) } ================================================ FILE: fmt/src/processor.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::collections::hash_map::Entry; use std::collections::HashMap; use std::fs; use std::path::Path; use std::path::PathBuf; use exn::bail; use exn::ensure; use exn::OptionExt; use exn::Result; use exn::ResultExt; use crate::config::Config; use crate::document::factory::DocumentFactory; use crate::document::model::default_mapping; use crate::document::Document; use crate::error::Error; use crate::git; use crate::header::matcher::HeaderMatcher; use crate::header::model::default_headers; use crate::header::model::deserialize_header_definitions; use crate::header::model::HeaderDef; use crate::license::bundled_headers; use crate::license::HeaderSource; use crate::selection::Selection; /// Callback for processing the result of checking license headers. pub trait Callback { /// Called when the header is unknown. fn on_unknown(&mut self, path: &Path); /// Called when the header is matched. fn on_matched(&mut self, header: &HeaderMatcher, document: Document) -> Result<(), Error>; /// Called when the header is not matched. fn on_not_matched(&mut self, header: &HeaderMatcher, document: Document) -> Result<(), Error>; } pub fn check_license_header( run_config: PathBuf, callback: &mut C, ) -> Result<(), Error> { let config = { let name = run_config.display().to_string(); let config = fs::read_to_string(&run_config) .or_raise(|| Error::new(format!("cannot load config: {name}")))?; toml::from_str::(&config) .or_raise(|| Error::new(format!("cannot parse config file: {name}")))? }; let config_dir = run_config .parent() .ok_or_raise(|| Error::new("cannot get parent directory of config file"))?; let basedir = config.base_dir.clone(); ensure!( basedir.is_dir(), Error::new(format!( "{} does not exist or is not a directory", basedir.display() )) ); let git_context = git::discover(&basedir, config.git)?; let selected_files = { let selection = Selection::new( basedir, config.header_path.as_ref(), &config.includes, &config.excludes, config.use_default_excludes, git_context.clone(), ); selection.select()? }; let mapping = { let mut mapping = config.mapping.clone(); if config.use_default_mapping { let default_mapping = default_mapping(); for m in default_mapping { if let Some(o) = mapping.get(&m) { log::warn!("default mapping {m:?} is override by {o:?}"); continue; } mapping.insert(m); } } mapping }; let definitions = { let mut defs = HashMap::new(); for (k, v) in default_headers() { match defs.entry(k) { Entry::Occupied(mut ent) => { log::warn!("Default header {} is override", ent.key()); ent.insert(v); } Entry::Vacant(ent) => { ent.insert(v); } } } for additional_header in &config.additional_headers { let additional_defs = load_additional_headers(additional_header, &config, config_dir)?; for (k, v) in additional_defs { match defs.entry(k) { Entry::Occupied(mut ent) => { log::warn!("Additional header {} is override", ent.key()); ent.insert(v); } Entry::Vacant(ent) => { ent.insert(v); } } } } defs }; let header_matcher = { let header_source = load_header_sources(&config, config_dir)?; HeaderMatcher::new(header_source.content) }; let git_file_attrs = git::resolve_file_attrs(git_context)?; let document_factory = DocumentFactory::new( mapping, definitions, config.properties, config.keywords, git_file_attrs, ); for file in selected_files { let document = match document_factory.create_document(&file)? { Some(document) => document, None => { callback.on_unknown(&file); continue; } }; if document.is_unsupported() { callback.on_unknown(&file); } else if document.header_matched(&header_matcher, config.strict_check)? { callback.on_matched(&header_matcher, document)?; } else { callback.on_not_matched(&header_matcher, document)?; } } Ok(()) } fn load_additional_headers( additional_header: impl AsRef, config: &Config, config_dir: &Path, ) -> Result, Error> { fn make_error(path: &Path) -> String { format!("cannot load additional header {}", path.display()) } let additional_header = additional_header.as_ref(); // 1. Based on config directory. let path = { let mut path = config_dir.to_path_buf(); path.push(additional_header); path }; if let Ok(content) = fs::read_to_string(&path) { return deserialize_header_definitions(content).or_raise(|| Error::new(make_error(&path))); } // 2. Based on the base_dir. let path = { let mut path = config.base_dir.clone(); path.push(additional_header); path }; if let Ok(content) = fs::read_to_string(&path) { return deserialize_header_definitions(content).or_raise(|| Error::new(make_error(&path))); } // 3. Based on current working directory. let path = additional_header; if let Ok(content) = fs::read_to_string(path) { return deserialize_header_definitions(content).or_raise(|| Error::new(make_error(path))); } bail!(Error::new(format!( "cannot find header definitions: {}", additional_header.display() ))) } fn load_header_sources(config: &Config, config_dir: &Path) -> Result { // 1. inline_header takes priority. if let Some(content) = config.inline_header.as_ref().cloned() { return Ok(HeaderSource { content }); } // 2. Then, try to load from header_path. let header_path = config.header_path.as_ref().ok_or_else(|| { Error::new("no header source found (both inline_header and header_path are None)") })?; // 2.1 Based on config directory. let path = { let mut path = config_dir.to_path_buf(); path.push(header_path); path }; if let Ok(content) = fs::read_to_string(path) { return Ok(HeaderSource { content }); } // 2.2 Based on the base_dir. let path = { let mut path = config.base_dir.clone(); path.push(header_path); path }; if let Ok(content) = fs::read_to_string(path) { return Ok(HeaderSource { content }); } // 2.3 Based on current working directory. if let Ok(content) = fs::read_to_string(header_path) { return Ok(HeaderSource { content }); } // 3. Finally, fallback to try bundled headers. bundled_headers(header_path).ok_or_raise(|| { Error::new(format!( "no header source found (header_path is invalid: {header_path})" )) }) } ================================================ FILE: fmt/src/selection.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::path::Path; use std::path::PathBuf; use exn::ensure; use exn::Result; use exn::ResultExt; use ignore::overrides::OverrideBuilder; use walkdir::WalkDir; use crate::error::Error; use crate::git::GitContext; pub struct Selection { basedir: PathBuf, includes: Vec, excludes: Vec, git_context: GitContext, } impl Selection { pub fn new( basedir: PathBuf, header_path: Option<&String>, includes: &[String], excludes: &[String], use_default_excludes: bool, git_context: GitContext, ) -> Selection { let includes = if includes.is_empty() { INCLUDES.iter().map(|s| s.to_string()).collect() } else { includes.to_vec() }; let input_excludes = excludes; let mut excludes = vec![]; if let Some(path) = header_path.cloned() { excludes.push(path); } if use_default_excludes { excludes.extend(EXCLUDES.iter().map(ToString::to_string)); } excludes.extend(input_excludes.to_vec()); Selection { basedir, includes, excludes, git_context, } } pub fn select(self) -> Result, Error> { log::debug!( "selecting files with baseDir: {}, included: {:?}, excluded: {:?}", self.basedir.display(), self.includes, self.excludes, ); let (excludes, reverse_excludes) = { let mut excludes = self.excludes; let reverse_excludes = excludes .extract_if(.., |pat| { if pat.starts_with('!') { pat.remove(0); true } else { false } }) .collect::>(); (excludes, reverse_excludes) }; let includes = self.includes; ensure!( includes.iter().all(|pat| !pat.starts_with('!')), Error::new(format!( "select files failed; reverse pattern is not allowed for includes: {includes:?}" )) ); let ignore = self.git_context.config.ignore.is_auto(); let result = match self.git_context.repo { None => select_files_with_ignore( &self.basedir, &includes, &excludes, &reverse_excludes, ignore, )?, Some(repo) => { select_files_with_git(&self.basedir, &includes, &excludes, &reverse_excludes, repo)? } }; log::debug!("selected files: {:?} (count: {})", result, result.len()); Ok(result) } } fn select_files_with_ignore( basedir: &PathBuf, includes: &[String], excludes: &[String], reverse_excludes: &[String], turn_on_git_ignore: bool, ) -> Result, Error> { let make_error = || Error::new("failed to select files with ignore crate"); log::debug!(turn_on_git_ignore; "Selecting files with ignore crate"); let mut result = vec![]; let walker = ignore::WalkBuilder::new(basedir) .ignore(false) // do not use .ignore file .hidden(false) // check hidden files .follow_links(true) // proper path name .parents(turn_on_git_ignore) .git_exclude(turn_on_git_ignore) .git_global(turn_on_git_ignore) .git_ignore(turn_on_git_ignore) .overrides({ let mut builder = OverrideBuilder::new(basedir); for pat in includes.iter() { builder.add(pat).or_raise(make_error)?; } for pat in excludes.iter() { let pat = format!("!{pat}"); builder.add(pat.as_str()).or_raise(make_error)?; } for pat in reverse_excludes.iter() { builder.add(pat).or_raise(make_error)?; } builder.build().or_raise(make_error)? }) .build(); for mat in walker { let mat = mat.or_raise(make_error)?; if mat.file_type().map(|ft| ft.is_file()).unwrap_or(false) { result.push(mat.into_path()) } } Ok(result) } fn select_files_with_git( basedir: &Path, includes: &[String], excludes: &[String], reverse_excludes: &[String], repo: gix::Repository, ) -> Result, Error> { log::debug!("selecting files with git helper"); let mut result = vec![]; let matcher = { let make_error = || Error::new("failed to select files with ignore crate"); let mut builder = OverrideBuilder::new(basedir); for pat in includes.iter() { builder.add(pat).or_raise(make_error)?; } for pat in excludes.iter() { let pat = format!("!{pat}"); builder.add(pat.as_str()).or_raise(make_error)?; } for pat in reverse_excludes.iter() { builder.add(pat).or_raise(make_error)?; } builder.build().or_raise(make_error)? }; let basedir = basedir.canonicalize().or_raise(|| { Error::new(format!( "cannot resolve absolute path: {}", basedir.display() )) })?; let mut it = WalkDir::new(basedir.clone()) .follow_links(false) .into_iter(); let workdir = repo.workdir().expect("workdir cannot be absent"); let workdir = workdir.canonicalize().or_raise(|| { Error::new(format!( "cannot resolve absolute path: {}", workdir.display() )) })?; let worktree = repo.worktree().expect("worktree cannot be absent"); let mut excludes = worktree .excludes(None) .or_raise(|| Error::new("cannot create gix exclude stack"))?; let index = repo .index_or_empty() .or_raise(|| Error::new("cannot open gix index"))?; while let Some(entry) = it.next() { let entry = entry.or_raise(|| Error::new("cannot traverse directory"))?; let path = entry.path(); let file_type = entry.file_type(); if !file_type.is_file() && !file_type.is_dir() { log::debug!(file_type:?; "skip file: {path:?}"); continue; } let rela_path = path .strip_prefix(&workdir) .expect("git repository encloses iteration"); let mode = Some(if file_type.is_dir() { gix::index::entry::Mode::DIR } else { gix::index::entry::Mode::FILE }); let platform = excludes .at_path(rela_path, mode) .or_raise(|| Error::new("cannot check gix exclude"))?; if file_type.is_dir() { if platform.is_excluded() { let rela = gix::path::try_into_bstr(rela_path) .or_raise(|| Error::new("cannot convert path to git path"))?; if !index.path_is_directory(rela.as_ref()) { log::debug!(path:?, rela_path:?; "skip git ignored directory"); it.skip_current_dir(); continue; } } if matcher.matched(rela_path, file_type.is_dir()).is_ignore() { log::debug!(path:?, rela_path:?; "skip glob ignored directory"); it.skip_current_dir(); continue; } } else if file_type.is_file() { if platform.is_excluded() { let rela = gix::path::try_into_bstr(rela_path) .or_raise(|| Error::new("cannot convert path to git path"))?; if index.entry_by_path(rela.as_ref()).is_none() { log::debug!(path:?, rela_path:?; "skip git ignored file"); continue; } } if !matcher .matched(rela_path, file_type.is_dir()) .is_whitelist() { log::debug!(path:?, rela_path:?; "skip glob ignored file"); continue; } result.push(path.to_path_buf()); } } Ok(result) } pub const INCLUDES: [&str; 1] = ["**"]; pub const EXCLUDES: [&str; 140] = [ // Miscellaneous typical temporary files "**/*~", "**/#*#", "**/.#*", "**/%*%", "**/._*", "**/.repository/**", "**/*.lck", // CVS "**/CVS", "**/CVS/**", "**/.cvsignore", // RCS "**/RCS", "**/RCS/**", // SCCS "**/SCCS", "**/SCCS/**", // Visual SourceSafe "**/vssver.scc", // Subversion "**/.svn", "**/.svn/**", // Arch "**/.arch-ids", "**/.arch-ids/**", // Bazaar "**/.bzr", "**/.bzr/**", // SurroundSCM "**/.MySCMServerInfo", // Mac "**/.DS_Store", // Docker ".dockerignore", // Serena Dimensions Version 10 "**/.metadata", "**/.metadata/**", // Mercurial "**/.hg", "**/.hg/**", "**/.hgignore", // git "**/.git", "**/.git/**", "**/.gitattributes", "**/.gitignore", "**/.gitkeep", "**/.gitmodules", // BitKeeper "**/BitKeeper", "**/BitKeeper/**", "**/ChangeSet", "**/ChangeSet/**", // darcs "**/_darcs", "**/_darcs/**", "**/.darcsrepo", "**/.darcsrepo/**", "**/-darcs-backup*", "**/.darcs-temp-mail", // maven project's temporary files "**/target/**", "**/test-output/**", "**/release.properties", "**/dependency-reduced-pom.xml", "**/release-pom.xml", "**/pom.xml.releaseBackup", "**/pom.xml.versionsBackup", // Node "**/node/**", "**/node_modules/**", // Yarn "**/.yarn/**", "**/yarn.lock", // pnpm "pnpm-lock.yaml", // Golang "**/go.sum", // Cargo "**/Cargo.lock", // code coverage tools "**/cobertura.ser", "**/.clover/**", "**/jacoco.exec", // eclipse project files "**/.classpath", "**/.project", "**/.settings/**", // IDEA project files "**/*.iml", "**/*.ipr", "**/*.iws", "**/.idea/**", // Netbeans "**/nb-configuration.xml", // Hibernate Validator Annotation Processor "**/.factorypath", // descriptors "**/MANIFEST.MF", // License files "**/LICENSE", "**/LICENSE_HEADER", // binary files - images "**/*.jpg", "**/*.png", "**/*.gif", "**/*.ico", "**/*.bmp", "**/*.tiff", "**/*.tif", "**/*.cr2", "**/*.xcf", // binary files - programs "**/*.class", "**/*.exe", "**/*.dll", "**/*.so", // checksum files "**/*.md5", "**/*.sha1", "**/*.sha256", "**/*.sha512", // Security files "**/*.asc", "**/*.jks", "**/*.keytab", "**/*.lic", "**/*.p12", "**/*.pub", // binary files - archives "**/*.jar", "**/*.zip", "**/*.rar", "**/*.tar", "**/*.tar.gz", "**/*.tar.bz2", "**/*.gz", "**/*.7z", // ServiceLoader files "**/META-INF/services/**", // Markdown files "**/*.md", // Office documents "**/*.xls", "**/*.doc", "**/*.odt", "**/*.ods", "**/*.pdf", // Travis "**/.travis.yml", // AppVeyor "**/.appveyor.yml", "**/appveyor.yml", // CircleCI "**/.circleci", "**/.circleci/**", // SourceHut "**/.build.yml", // Maven 3.3+ configs "**/jvm.config", "**/maven.config", // Wrappers "**/gradlew", "**/gradlew.bat", "**/gradle-wrapper.properties", "**/mvnw", "**/mvnw.cmd", "**/maven-wrapper.properties", "**/MavenWrapperDownloader.java", // flash "**/*.swf", // json files "**/*.json", // fonts "**/*.svg", "**/*.eot", "**/*.otf", "**/*.ttf", "**/*.woff", "**/*.woff2", // logs "**/*.log", // office documents "**/*.xlsx", "**/*.docx", "**/*.ppt", "**/*.pptx", ]; ================================================ FILE: fmt/tests/content/empty.py ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: fmt/tests/content/two_headers.rs ================================================ // Copyright 2023 Greptime Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // This file also contains some code from prometheus project. // Copyright 2015 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Implementations of `rate`, `increase` and `delta` functions in PromQL. use std::fmt::Display; use std::sync::Arc; ================================================ FILE: fmt/tests/tests.rs ================================================ // Copyright 2024 tison // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::path::Path; use hawkeye_fmt::header::model::default_headers; use hawkeye_fmt::header::parser::parse_header; use hawkeye_fmt::header::parser::FileContent; #[test] fn test_remove_file_only_header() { let file = Path::new("tests/content/empty.py"); let defs = default_headers(); let def = defs.get("script_style").unwrap().clone(); let keywords = vec!["copyright".to_string()]; let file_content = FileContent::new(file).unwrap(); let document = parse_header(file_content, &def, &keywords); let end_pos = document.end_pos.unwrap(); let content = document.file_content.content(); assert!(content[end_pos..].trim().is_empty()); } #[test] fn test_two_headers_should_only_remove_the_first() { let file = Path::new("tests/content/two_headers.rs"); let defs = default_headers(); let def = defs.get("doubleslash_style").unwrap().clone(); let keywords = vec!["copyright".to_string()]; let file_content = FileContent::new(file).unwrap(); let document = parse_header(file_content, &def, &keywords); let end_pos = document.end_pos.unwrap(); let content = document.file_content.content(); assert!(content[end_pos..].contains("Copyright 2015 The Prometheus Authors")); } ================================================ FILE: licenserc.toml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. headerPath = "Apache-2.0.txt" excludes = [ # Plain text files AS IS "*.txt", # Test files "fmt/tests/content/**", "tests/**", "!tests/it.py", # Generated files ".github/workflows/release.yml", ] [git] attrs = 'auto' ignore = 'auto' [properties] inceptionYear = 2024 copyrightOwner = "tison " projectName = "HawkEye" ================================================ FILE: pyproject.toml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [build-system] requires = ["maturin>=1.0,<2.0"] build-backend = "maturin" [tool.maturin] bindings = "bin" manifest-path = "cli/Cargo.toml" ================================================ FILE: rust-toolchain.toml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [toolchain] channel = "stable" components = ["cargo", "rustfmt", "clippy", "rust-analyzer"] ================================================ FILE: rustfmt.toml ================================================ # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. imports_granularity = "Item" group_imports = "StdExternalCrate" comment_width = 120 wrap_comments = true format_code_in_doc_comments = true ================================================ FILE: tests/.gitignore ================================================ *.formatted ================================================ FILE: tests/attrs_and_props/license.txt ================================================ props["inceptionYear"]={{props["inceptionYear"]}} props["copyrightOwner"]={{props["copyrightOwner"]}} attrs.filename={{attrs.filename}} attrs.git_file_created_year={{attrs.git_file_created_year}} attrs.git_file_modified_year={{attrs.git_file_modified_year}} ================================================ FILE: tests/attrs_and_props/licenserc.toml ================================================ baseDir = "." headerPath = "license.txt" excludes = ["licenserc.toml", "*.expected"] [git] ignore = 'auto' attrs = 'enable' [properties] inceptionYear = 2024 copyrightOwner = "Mike Delaney " ================================================ FILE: tests/attrs_and_props/main.rs ================================================ fn main() { println!("Hello, world!"); } ================================================ FILE: tests/attrs_and_props/main.rs.expected ================================================ // props["inceptionYear"]=2024 // props["copyrightOwner"]=Mike Delaney // // attrs.filename=main.rs // attrs.git_file_created_year=2025 // attrs.git_file_modified_year=2025 fn main() { println!("Hello, world!"); } ================================================ FILE: tests/bom_issue/headless_bom.cs ================================================ using System; namespace Some.Fake.Namespace; public class SomeFakeClass { public void SomeFakeMethod() { Console.WriteLine("Hello, World!"); } } ================================================ FILE: tests/bom_issue/headless_bom.cs.expected ================================================ // ------------------------------------------------------- // Static Copyright // ------------------------------------------------------- using System; namespace Some.Fake.Namespace; public class SomeFakeClass { public void SomeFakeMethod() { Console.WriteLine("Hello, World!"); } } ================================================ FILE: tests/bom_issue/license.txt ================================================ Static Copyright ================================================ FILE: tests/bom_issue/licenserc.toml ================================================ baseDir = "." headerPath = "license.txt" excludes = ["*.toml", "*.expected"] additionalHeaders = ["style.toml"] [mapping.CS_TEST_STYLE] extensions = ["cs"] ================================================ FILE: tests/bom_issue/style.toml ================================================ [CS_TEST_STYLE] firstLine = "// -------------------------------------------------------" endLine = "// -------------------------------------------------------\n" skipLinePattern = "^#!.*$" allowBlankLines = false multipleLines = false beforeEachLine = "// " firstLineDetectionPattern = "//\\s+\\-+" lastLineDetectionPattern = "//\\s+\\-+" ================================================ FILE: tests/disk_file_created_year/license.txt ================================================ Copyright {{attrs.git_file_created_year if attrs.git_file_created_year else attrs.disk_file_created_year }} ================================================ FILE: tests/disk_file_created_year/licenserc.toml ================================================ baseDir = "." headerPath = "license.txt" excludes = ["licenserc.toml", "*.expected"] ================================================ FILE: tests/disk_file_created_year/main.rs ================================================ fn main() { println!("Hello, world!"); } ================================================ FILE: tests/disk_file_created_year/main.rs.expected ================================================ // Copyright fn main() { println!("Hello, world!"); } ================================================ FILE: tests/it.py ================================================ #!/usr/bin/env python3 # Copyright 2024 tison # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from pathlib import Path import difflib import subprocess import os import shutil import datetime def diff_files(file1, file2): with file1.open("r", encoding="utf8") as f1, file2.open("r", encoding="utf8") as f2: diff = difflib.unified_diff(f1.readlines(), f2.readlines(), str(file1), str(file2)) diff = list(diff) if diff: for line in diff: print(line, end="") exit(1) basedir = Path(__file__).parent.absolute() rootdir = basedir.parent subprocess.run(["cargo", "build", "--bin", "hawkeye"], cwd=rootdir, check=True) hawkeye = rootdir / "target" / "debug" / "hawkeye" def drive(name, files, create_temp_copy=False): temp_paths = [] expected_files = [] case_dir = basedir / name try: if create_temp_copy: current_year = str(datetime.datetime.now().year) for filepath in files: base, ext = os.path.splitext(filepath) temp_path = f"{base}_temp{ext}" shutil.copy2(case_dir / filepath, case_dir / temp_path) temp_paths.append(temp_path) expected_temp_path = f"{base}_temp{ext}.expected" shutil.copy2(case_dir / f"{filepath}.expected", case_dir / expected_temp_path) expected_files.append(expected_temp_path) with (case_dir / expected_temp_path).open("r", encoding="utf8") as f: content = f.read() content = content.replace("", current_year) with (case_dir / expected_temp_path).open("w", encoding="utf8") as f: f.write(content) else: temp_paths = files expected_files = [f"{file}.expected" for file in files] subprocess.run([hawkeye, "format", "--fail-if-unknown", "--fail-if-updated=false", "--dry-run"], cwd=case_dir, check=True) for file in temp_paths: diff_files(case_dir / f"{file}.expected", case_dir / f"{file}.formatted") finally: # Remove all temp files at the end if create_temp_copy: for temp_path in temp_paths: if os.path.exists(case_dir / temp_path): os.remove(case_dir / temp_path) for expected_file in expected_files: if os.path.exists(case_dir / expected_file): os.remove(case_dir / expected_file) drive("attrs_and_props", ["main.rs"]) drive("load_header_path", ["main.rs"]) drive("bom_issue", ["headless_bom.cs"]) drive("regression_blank_line", ["main.rs"]) drive("regression_no_blank_lines", ["repro.py"]) drive("disk_file_created_year", ["main.rs"], True) ================================================ FILE: tests/load_header_path/license.txt ================================================ Copyright {{props["inceptionYear"]}} {{props["copyrightOwner"]}} Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. 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. 3. Neither the name of the copyright holder 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 HOLDER 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. ================================================ FILE: tests/load_header_path/licenserc.toml ================================================ baseDir = "." headerPath = "license.txt" excludes = ["licenserc.toml", "*.expected"] [properties] inceptionYear = 2024 copyrightOwner = "Mike Delaney " ================================================ FILE: tests/load_header_path/main.rs ================================================ fn main() { println!("Hello, world!"); } ================================================ FILE: tests/load_header_path/main.rs.expected ================================================ // Copyright 2024 Mike Delaney // // Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: // // 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. // // 2. 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. // // 3. Neither the name of the copyright holder 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 HOLDER 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. fn main() { println!("Hello, world!"); } ================================================ FILE: tests/regression_blank_line/licenserc.toml ================================================ baseDir = "." headerPath = "Apache-2.0.txt" includes = ["*.rs"] excludes = ["*.expected"] [properties] inceptionYear = 2023 copyrightOwner = "The CopyrightOwner" ================================================ FILE: tests/regression_blank_line/main.rs ================================================ // Copyright 2022-2023 The Authors. Licensed under Apache-2.0. //! Load balancer use macros::define_result; ================================================ FILE: tests/regression_blank_line/main.rs.expected ================================================ // Copyright 2023 The CopyrightOwner // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Load balancer use macros::define_result; ================================================ FILE: tests/regression_no_blank_lines/licenserc.toml ================================================ # Copyright (c) 2022-2025, Example, All Rights Reserved. inlineHeader = "Copyright (c) 2022-2025, Example, All Rights Reserved." baseDir = "." includes = ["*.py"] excludes = ["*.expected"] ================================================ FILE: tests/regression_no_blank_lines/repro.py ================================================ # Copyright (c) 2022-2025, Example, All Rights Reserved. from __future__ import annotations if __name__ == "__main__": print() ================================================ FILE: tests/regression_no_blank_lines/repro.py.expected ================================================ # Copyright (c) 2022-2025, Example, All Rights Reserved. from __future__ import annotations if __name__ == "__main__": print()