├── .gitignore ├── rust-toolchain.toml ├── .ignore ├── .github ├── renovate.json └── workflows │ ├── release.yml │ ├── deny.yml │ ├── zizmor.yml │ └── ci.yml ├── dprint.json ├── .rustfmt.toml ├── CHANGELOG.md ├── justfile ├── LICENSE ├── Cargo.toml ├── Cargo.lock ├── README.md ├── tests └── main.rs ├── deny.toml └── src └── lib.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.92.0" 3 | profile = "default" 4 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | # For watchexec https://github.com/watchexec/watchexec/tree/main/crates/cli#features 2 | 3 | target/** 4 | **/node_modules/** 5 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>Boshen/renovate"] 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: {} 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | release-plz: 12 | name: Release-plz 13 | runs-on: ubuntu-latest 14 | permissions: 15 | pull-requests: write 16 | contents: write 17 | id-token: write 18 | steps: 19 | - uses: oxc-project/release-plz@44b98e8dda1a7783d4ec2ef66e2f37a3e8c1c759 # v1.0.4 20 | with: 21 | PAT: ${{ secrets.OXC_BOT_PAT }} 22 | -------------------------------------------------------------------------------- /dprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "lineWidth": 120, 3 | "typescript": { 4 | "quoteStyle": "preferSingle", 5 | "binaryExpression.operatorPosition": "sameLine" 6 | }, 7 | "json": { 8 | "indentWidth": 2 9 | }, 10 | "toml": { 11 | }, 12 | "excludes": [], 13 | "plugins": [ 14 | "https://plugins.dprint.dev/typescript-0.95.13.wasm", 15 | "https://plugins.dprint.dev/json-0.21.0.wasm", 16 | "https://plugins.dprint.dev/markdown-0.20.0.wasm", 17 | "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.1.wasm", 18 | "https://plugins.dprint.dev/toml-0.7.0.wasm" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | style_edition = "2024" 2 | 3 | # Make Rust more readable given most people have wide screens nowadays. 4 | # This is also the setting used by [rustc](https://github.com/rust-lang/rust/blob/master/rustfmt.toml) 5 | use_small_heuristics = "Max" 6 | 7 | # Use field initialize shorthand if possible 8 | use_field_init_shorthand = true 9 | 10 | reorder_modules = true 11 | 12 | # All unstable features that we wish for 13 | # unstable_features = true 14 | # Provide a cleaner impl order 15 | # reorder_impl_items = true 16 | # Provide a cleaner import sort order 17 | # group_imports = "StdExternalCrate" 18 | # Group "use" statements by crate 19 | # imports_granularity = "Crate" 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.0.3](https://github.com/oxc-project/editorconfig-parser/compare/v0.0.2...v0.0.3) - 2025-12-18 11 | 12 | ### Added 13 | 14 | - Support cwd as root directory ([#5](https://github.com/oxc-project/editorconfig-parser/pull/5)) 15 | 16 | ## [0.0.2](https://github.com/oxc-project/editorconfig-parser/compare/v0.0.1...v0.0.2) - 2025-12-10 17 | 18 | ### Added 19 | 20 | - implement path resolution and property unset ([#1](https://github.com/oxc-project/editorconfig-parser/pull/1)) 21 | - resolve a path and return its properties 22 | 23 | ### Other 24 | 25 | - compile glob once 26 | - add readme 27 | - support max_line_length off value 28 | -------------------------------------------------------------------------------- /.github/workflows/deny.yml: -------------------------------------------------------------------------------- 1 | name: Cargo Deny 2 | 3 | permissions: {} 4 | 5 | on: 6 | workflow_dispatch: 7 | pull_request: 8 | types: [opened, synchronize] 9 | paths: 10 | - "Cargo.lock" 11 | - "deny.toml" 12 | - ".github/workflows/deny.yml" 13 | push: 14 | branches: 15 | - main 16 | paths: 17 | - "Cargo.lock" 18 | - "deny.toml" 19 | - ".github/workflows/deny.yml" 20 | 21 | concurrency: 22 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} 23 | cancel-in-progress: ${{ github.ref_name != 'main' }} 24 | 25 | jobs: 26 | deny: 27 | name: Cargo Deny 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: taiki-e/checkout-action@b13d20b7cda4e2f325ef19895128f7ff735c0b3d # v1.3.1 31 | 32 | - uses: oxc-project/setup-rust@ecabb7322a2ba5aeedb3612d2a40b86a85cee235 # v1.0.11 33 | with: 34 | restore-cache: false 35 | tools: cargo-deny 36 | 37 | - run: cargo deny check 38 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S just --justfile 2 | 3 | set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] 4 | set shell := ["bash", "-cu"] 5 | 6 | _default: 7 | @just --list -u 8 | 9 | alias r := ready 10 | 11 | init: 12 | cargo binstall watchexec-cli cargo-insta typos-cli cargo-shear dprint -y 13 | 14 | ready: 15 | git diff --exit-code --quiet 16 | typos 17 | just fmt 18 | just check 19 | just test 20 | just lint 21 | just doc 22 | 23 | watch *args='': 24 | watchexec --no-vcs-ignore {{args}} 25 | 26 | fmt: 27 | cargo shear --fix 28 | cargo fmt --all 29 | dprint fmt 30 | 31 | check: 32 | cargo check --workspace --all-features --all-targets --locked 33 | 34 | watch-check: 35 | just watch "'cargo check; cargo clippy'" 36 | 37 | test: 38 | cargo test 39 | 40 | lint: 41 | cargo clippy --workspace --all-targets --all-features -- --deny warnings 42 | 43 | [unix] 44 | doc: 45 | RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --document-private-items 46 | 47 | [windows] 48 | doc: 49 | $Env:RUSTDOCFLAGS='-D warnings'; cargo doc --no-deps --document-private-items 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-present VoidZero Inc. & Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/zizmor.yml: -------------------------------------------------------------------------------- 1 | name: Zizmor 2 | 3 | permissions: {} 4 | 5 | on: 6 | workflow_dispatch: 7 | pull_request: 8 | types: [opened, synchronize] 9 | paths: 10 | - ".github/workflows/**" 11 | push: 12 | branches: 13 | - main 14 | paths: 15 | - ".github/workflows/**" 16 | 17 | jobs: 18 | zizmor: 19 | name: zizmor 20 | runs-on: ubuntu-latest 21 | permissions: 22 | security-events: write 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 26 | with: 27 | persist-credentials: false 28 | 29 | - uses: taiki-e/install-action@61e5998d108b2b55a81b9b386c18bd46e4237e4f # v2.63.1 30 | with: 31 | tool: zizmor 32 | 33 | - name: Run zizmor 34 | run: zizmor --format sarif . > results.sarif 35 | env: 36 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - name: Upload SARIF file 39 | uses: github/codeql-action/upload-sarif@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8 40 | with: 41 | sarif_file: results.sarif 42 | category: zizmor 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | permissions: {} 4 | 5 | on: 6 | workflow_dispatch: 7 | pull_request: 8 | types: [opened, synchronize] 9 | push: 10 | branches: 11 | - main 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} 15 | cancel-in-progress: ${{ github.ref_name != 'main' }} 16 | 17 | defaults: 18 | run: 19 | shell: bash 20 | 21 | env: 22 | CARGO_INCREMENTAL: 0 23 | RUSTFLAGS: "-D warnings" 24 | 25 | jobs: 26 | test: 27 | name: Test 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | include: 32 | - os: ubuntu-latest 33 | - os: windows-latest 34 | - os: macos-14 35 | runs-on: ${{ matrix.os }} 36 | steps: 37 | - uses: taiki-e/checkout-action@b13d20b7cda4e2f325ef19895128f7ff735c0b3d # v1.3.1 38 | - uses: oxc-project/setup-rust@ecabb7322a2ba5aeedb3612d2a40b86a85cee235 # v1.0.11 39 | with: 40 | save-cache: ${{ github.ref_name == 'main' }} 41 | - run: cargo check --all-targets --all-features 42 | - run: cargo test 43 | 44 | lint: 45 | name: Lint 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: taiki-e/checkout-action@b13d20b7cda4e2f325ef19895128f7ff735c0b3d # v1.3.1 49 | 50 | - uses: oxc-project/setup-rust@ecabb7322a2ba5aeedb3612d2a40b86a85cee235 # v1.0.11 51 | with: 52 | components: clippy rust-docs rustfmt 53 | 54 | - run: | 55 | cargo fmt --check 56 | cargo clippy --all-targets --all-features -- -D warnings 57 | RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --document-private-items 58 | 59 | - uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 # v1.40.0 60 | with: 61 | files: . 62 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "editorconfig-parser" 3 | version = "0.0.3" 4 | authors = ["Boshen "] 5 | categories = [] 6 | edition = "2024" 7 | include = ["/src"] 8 | keywords = [] 9 | license = "MIT" 10 | publish = true 11 | readme = "README.md" 12 | repository = "https://github.com/oxc-project/editorconfig-parser" 13 | description = ".editorconfig parser" 14 | 15 | [lib] 16 | test = false 17 | doctest = false 18 | 19 | [lints.rust] 20 | absolute_paths_not_starting_with_crate = "warn" 21 | non_ascii_idents = "warn" 22 | unit-bindings = "warn" 23 | unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)', 'cfg(coverage_nightly)'] } 24 | 25 | [lints.clippy] 26 | all = { level = "warn", priority = -1 } 27 | # restriction 28 | dbg_macro = "warn" 29 | todo = "warn" 30 | unimplemented = "warn" 31 | print_stdout = "warn" # Must be opt-in 32 | print_stderr = "warn" # Must be opt-in 33 | allow_attributes = "warn" 34 | # I like the explicitness of this rule as it removes confusion around `clone`. 35 | # This increases readability, avoids `clone` mindlessly and heap allocating by accident. 36 | clone_on_ref_ptr = "warn" 37 | # These two are mutually exclusive, I like `mod.rs` files for better fuzzy searches on module entries. 38 | self_named_module_files = "warn" # "-Wclippy::mod_module_files" 39 | empty_drop = "warn" 40 | empty_structs_with_brackets = "warn" 41 | exit = "warn" 42 | filetype_is_file = "warn" 43 | get_unwrap = "warn" 44 | impl_trait_in_params = "warn" 45 | rc_buffer = "warn" 46 | rc_mutex = "warn" 47 | rest_pat_in_fully_bound_structs = "warn" 48 | unnecessary_safety_comment = "warn" 49 | undocumented_unsafe_blocks = "warn" 50 | infinite_loop = "warn" 51 | 52 | [dependencies] 53 | globset = "0.4.18" 54 | 55 | [profile.release] 56 | # Configurations explicitly listed here for clarity. 57 | # Using the best options for performance. 58 | opt-level = 3 59 | lto = "fat" 60 | codegen-units = 1 61 | strip = "symbols" # set to `false` for debug information 62 | debug = false # set to `true` for debug information 63 | panic = "abort" # Let it crash and force ourselves to write safe Rust. 64 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.4" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "bstr" 16 | version = "1.12.1" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" 19 | dependencies = [ 20 | "memchr", 21 | "serde", 22 | ] 23 | 24 | [[package]] 25 | name = "editorconfig-parser" 26 | version = "0.0.3" 27 | dependencies = [ 28 | "globset", 29 | ] 30 | 31 | [[package]] 32 | name = "globset" 33 | version = "0.4.18" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" 36 | dependencies = [ 37 | "aho-corasick", 38 | "bstr", 39 | "log", 40 | "regex-automata", 41 | "regex-syntax", 42 | ] 43 | 44 | [[package]] 45 | name = "log" 46 | version = "0.4.29" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 49 | 50 | [[package]] 51 | name = "memchr" 52 | version = "2.7.6" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 55 | 56 | [[package]] 57 | name = "proc-macro2" 58 | version = "1.0.103" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" 61 | dependencies = [ 62 | "unicode-ident", 63 | ] 64 | 65 | [[package]] 66 | name = "quote" 67 | version = "1.0.42" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" 70 | dependencies = [ 71 | "proc-macro2", 72 | ] 73 | 74 | [[package]] 75 | name = "regex-automata" 76 | version = "0.4.13" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" 79 | dependencies = [ 80 | "aho-corasick", 81 | "memchr", 82 | "regex-syntax", 83 | ] 84 | 85 | [[package]] 86 | name = "regex-syntax" 87 | version = "0.8.8" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 90 | 91 | [[package]] 92 | name = "serde" 93 | version = "1.0.228" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 96 | dependencies = [ 97 | "serde_core", 98 | ] 99 | 100 | [[package]] 101 | name = "serde_core" 102 | version = "1.0.228" 103 | source = "registry+https://github.com/rust-lang/crates.io-index" 104 | checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 105 | dependencies = [ 106 | "serde_derive", 107 | ] 108 | 109 | [[package]] 110 | name = "serde_derive" 111 | version = "1.0.228" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 114 | dependencies = [ 115 | "proc-macro2", 116 | "quote", 117 | "syn", 118 | ] 119 | 120 | [[package]] 121 | name = "syn" 122 | version = "2.0.111" 123 | source = "registry+https://github.com/rust-lang/crates.io-index" 124 | checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" 125 | dependencies = [ 126 | "proc-macro2", 127 | "quote", 128 | "unicode-ident", 129 | ] 130 | 131 | [[package]] 132 | name = "unicode-ident" 133 | version = "1.0.22" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # editorconfig-parser 4 | 5 | [![Crates.io][crates-badge]][crates-url] 6 | [![Docs.rs][docs-badge]][docs-url] 7 | 8 | [![MIT licensed][license-badge]][license-url] 9 | [![Build Status][ci-badge]][ci-url] 10 | [![Code Coverage][code-coverage-badge]][code-coverage-url] 11 | [![CodSpeed Badge][codspeed-badge]][codspeed-url] 12 | [![Sponsors][sponsors-badge]][sponsors-url] 13 | [![Discord chat][discord-badge]][discord-url] 14 | 15 |
16 | 17 | A fast, spec-compliant Rust implementation of an [EditorConfig](https://editorconfig.org/) parser. 18 | 19 | ## Features 20 | 21 | - **Spec-compliant** - fully implements the [EditorConfig specification](https://spec.editorconfig.org/) 22 | - **Zero dependencies** - pure Rust implementation with no external dependencies 23 | - **Fast and safe** - no unsafe code, optimized for performance 24 | - **Comprehensive property support** - handles all standard EditorConfig properties 25 | - **Path resolution** - resolves properties for specific file paths 26 | 27 | ## Usage 28 | 29 | Add this to your `Cargo.toml`: 30 | 31 | ```toml 32 | [dependencies] 33 | editorconfig-parser = "0.0.1" 34 | ``` 35 | 36 | ### Parsing an EditorConfig file 37 | 38 | ```rust 39 | use editorconfig_parser::EditorConfig; 40 | 41 | let config_text = r#" 42 | root = true 43 | 44 | [*] 45 | indent_style = space 46 | indent_size = 4 47 | end_of_line = lf 48 | charset = utf-8 49 | trim_trailing_whitespace = true 50 | insert_final_newline = true 51 | 52 | [*.md] 53 | max_line_length = off 54 | 55 | [Makefile] 56 | indent_style = tab 57 | "#; 58 | 59 | let config = EditorConfig::parse(config_text); 60 | 61 | // Check if this is a root config 62 | assert!(config.root()); 63 | 64 | // Access sections 65 | for section in config.sections() { 66 | println!("Section: {}", section.name); 67 | if let Some(indent_style) = section.properties.indent_style { 68 | println!(" indent_style: {:?}", indent_style); 69 | } 70 | } 71 | ``` 72 | 73 | ### Resolving properties for a file path 74 | 75 | ```rust 76 | use editorconfig_parser::EditorConfig; 77 | use std::path::Path; 78 | 79 | let config = EditorConfig::parse(config_text); 80 | let properties = config.resolve(Path::new("src/main.rs")); 81 | ``` 82 | 83 | ## Supported Properties 84 | 85 | The parser supports all standard EditorConfig properties: 86 | 87 | | Property | Type | Values | 88 | |----------|------|--------| 89 | | `indent_style` | `IdentStyle` | `tab`, `space` | 90 | | `indent_size` | `usize` | Positive integer | 91 | | `tab_width` | `usize` | Positive integer | 92 | | `end_of_line` | `EndOfLine` | `lf`, `cr`, `crlf` | 93 | | `charset` | `Charset` | `latin1`, `utf-8`, `utf-8-bom`, `utf-16be`, `utf-16le` | 94 | | `trim_trailing_whitespace` | `bool` | `true`, `false` | 95 | | `insert_final_newline` | `bool` | `true`, `false` | 96 | | `max_line_length` | `MaxLineLength` | Positive integer or `off` | 97 | 98 | Note: `max_line_length` is not part of the official EditorConfig spec but is commonly used by tools like [Prettier](https://prettier.io/docs/next/configuration#editorconfig). 99 | 100 | ## How It Works 101 | 102 | The parser follows the [EditorConfig specification](https://spec.editorconfig.org/index.html#id6): 103 | 104 | 1. Reads the file line by line 105 | 2. Removes leading and trailing whitespace 106 | 3. Ignores blank lines and comments (`#` or `;`) 107 | 4. Parses `root = true` in the preamble (before any sections) 108 | 5. Parses section headers `[pattern]` as glob patterns 109 | 6. Parses key-value pairs `key = value` within sections 110 | 7. All values are case-insensitive 111 | 112 | ## Development 113 | 114 | ### Building 115 | 116 | ```bash 117 | cargo build --release 118 | ``` 119 | 120 | ### Running Tests 121 | 122 | ```bash 123 | cargo test 124 | ``` 125 | 126 | ## License 127 | 128 | MIT 129 | 130 | ## References 131 | 132 | - [EditorConfig Specification](https://spec.editorconfig.org/) 133 | - [EditorConfig Official Site](https://editorconfig.org/) 134 | 135 | ## [Sponsored By](https://github.com/sponsors/Boshen) 136 | 137 |

138 | 139 | My sponsors 140 | 141 |

142 | 143 | [discord-badge]: https://img.shields.io/discord/1079625926024900739?logo=discord&label=Discord 144 | [discord-url]: https://discord.gg/9uXCAwqQZW 145 | [license-badge]: https://img.shields.io/badge/license-MIT-blue.svg 146 | [license-url]: https://github.com/oxc-project/editorconfig-parser/blob/main/LICENSE 147 | [ci-badge]: https://github.com/oxc-project/editorconfig-parser/actions/workflows/ci.yml/badge.svg?event=push&branch=main 148 | [ci-url]: https://github.com/oxc-project/editorconfig-parser/actions/workflows/ci.yml?query=event%3Apush+branch%3Amain 149 | [code-coverage-badge]: https://codecov.io/github/oxc-project/editorconfig-parser/branch/main/graph/badge.svg 150 | [code-coverage-url]: https://codecov.io/gh/oxc-project/editorconfig-parser 151 | [sponsors-badge]: https://img.shields.io/github/sponsors/Boshen 152 | [sponsors-url]: https://github.com/sponsors/Boshen 153 | [codspeed-badge]: https://img.shields.io/endpoint?url=https://codspeed.io/badge.json 154 | [codspeed-url]: https://codspeed.io/oxc-project/editorconfig-parser 155 | [crates-badge]: https://img.shields.io/crates/d/editorconfig-parser?label=crates.io 156 | [crates-url]: https://crates.io/crates/editorconfig-parser 157 | [docs-badge]: https://img.shields.io/docsrs/editorconfig-parser 158 | [docs-url]: https://docs.rs/editorconfig-parser 159 | -------------------------------------------------------------------------------- /tests/main.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use editorconfig_parser::{ 4 | Charset, EditorConfig, EditorConfigProperties, EditorConfigProperty::Value, EndOfLine, 5 | IndentStyle, MaxLineLength, 6 | }; 7 | 8 | #[test] 9 | fn empty() { 10 | let editor_config = EditorConfig::parse(""); 11 | assert!(editor_config.sections().is_empty()); 12 | } 13 | 14 | #[test] 15 | fn comment() { 16 | let editor_config = EditorConfig::parse(";"); 17 | assert!(editor_config.sections().is_empty()); 18 | 19 | let editor_config = EditorConfig::parse("#"); 20 | assert!(editor_config.sections().is_empty()); 21 | } 22 | 23 | #[test] 24 | fn root() { 25 | let editor_config = EditorConfig::parse("root = true"); 26 | assert!(editor_config.sections().is_empty()); 27 | assert!(editor_config.root()); 28 | 29 | let editor_config = EditorConfig::parse("root = false"); 30 | assert!(!editor_config.root()); 31 | } 32 | 33 | #[test] 34 | fn sections() { 35 | let editor_config = EditorConfig::parse( 36 | " 37 | [*] 38 | [**] 39 | [?] 40 | ", 41 | ); 42 | assert_eq!(editor_config.sections().len(), 3); 43 | } 44 | 45 | #[test] 46 | fn values() { 47 | let editor_config = EditorConfig::parse( 48 | " 49 | [*] 50 | charset = utf-8 51 | insert_final_newline = true 52 | end_of_line = lf 53 | indent_style = space 54 | indent_size = 2 55 | max_line_length = 80", 56 | ); 57 | assert_eq!(editor_config.sections().len(), 1); 58 | let properties = &editor_config.sections()[0].properties; 59 | assert_eq!(properties.charset, Value(Charset::Utf8)); 60 | assert_eq!(properties.insert_final_newline, Value(true)); 61 | assert_eq!(properties.end_of_line, Value(EndOfLine::Lf)); 62 | assert_eq!(properties.indent_style, Value(IndentStyle::Space)); 63 | assert_eq!(properties.indent_size, Value(2)); 64 | assert_eq!(properties.max_line_length, Value(MaxLineLength::Number(80))); 65 | } 66 | 67 | #[test] 68 | fn max_line_length_off() { 69 | let editor_config = EditorConfig::parse( 70 | " 71 | [*] 72 | max_line_length = off", 73 | ); 74 | assert_eq!(editor_config.sections().len(), 1); 75 | let properties = &editor_config.sections()[0].properties; 76 | assert_eq!(properties.max_line_length, Value(MaxLineLength::Off)); 77 | } 78 | 79 | #[test] 80 | fn resolve() { 81 | let editor_config = EditorConfig::parse( 82 | " 83 | # * 84 | [*] 85 | charset = utf-8 86 | insert_final_newline = true 87 | end_of_line = lf 88 | indent_style = space 89 | indent_size = 2 90 | max_line_length = 80 91 | 92 | ; foo 93 | [*.foo] 94 | charset = latin1 95 | insert_final_newline = false 96 | end_of_line = crlf 97 | indent_style = tab 98 | indent_size = 4 99 | max_line_length = 100 100 | 101 | [*.{ts,tsx,js,jsx,mts,cts}] 102 | indent_size = 8 103 | max_line_length = 120 104 | 105 | [*.rs] 106 | max_line_length = 140 107 | 108 | [**/__snapshots__/**] 109 | max_line_length = 160 110 | ", 111 | ); 112 | 113 | let path = Path::new("/"); 114 | let all = editor_config.resolve(path); 115 | assert_eq!(all.charset, Value(Charset::Utf8)); 116 | assert_eq!(all.insert_final_newline, Value(true)); 117 | assert_eq!(all.end_of_line, Value(EndOfLine::Lf)); 118 | assert_eq!(all.indent_style, Value(IndentStyle::Space)); 119 | assert_eq!(all.indent_size, Value(2)); 120 | assert_eq!(all.max_line_length, Value(MaxLineLength::Number(80))); 121 | 122 | let properties = editor_config.resolve(&path.join("file.foo")); 123 | assert_eq!(properties.charset, Value(Charset::Latin1)); 124 | assert_eq!(properties.insert_final_newline, Value(false)); 125 | assert_eq!(properties.end_of_line, Value(EndOfLine::Crlf)); 126 | assert_eq!(properties.indent_style, Value(IndentStyle::Tab)); 127 | assert_eq!(properties.indent_size, Value(4)); 128 | assert_eq!(properties.max_line_length, Value(MaxLineLength::Number(100))); 129 | 130 | for ext in ["ts", "tsx", "js", "jsx", "mts", "cts"] { 131 | assert_eq!( 132 | editor_config.resolve(&path.join("file").with_extension(ext)), 133 | EditorConfigProperties { 134 | indent_size: Value(8), 135 | max_line_length: Value(MaxLineLength::Number(120)), 136 | ..all.clone() 137 | } 138 | ); 139 | } 140 | 141 | assert_eq!( 142 | editor_config.resolve(&path.join("file.rs")), 143 | EditorConfigProperties { 144 | max_line_length: Value(MaxLineLength::Number(140)), 145 | ..all.clone() 146 | } 147 | ); 148 | 149 | assert_eq!( 150 | editor_config.resolve(&path.join("dir").join("__snapshots__").join("file")), 151 | EditorConfigProperties { 152 | max_line_length: Value(MaxLineLength::Number(160)), 153 | ..all.clone() 154 | } 155 | ); 156 | } 157 | 158 | #[test] 159 | fn unset() { 160 | let editor_config = EditorConfig::parse( 161 | " 162 | [*] 163 | charset = utf-8 164 | insert_final_newline = true 165 | end_of_line = lf 166 | indent_style = space 167 | indent_size = 2 168 | max_line_length = 80 169 | 170 | [*.foo] 171 | charset = unset 172 | insert_final_newline = unset 173 | end_of_line = unset 174 | indent_style = unset 175 | indent_size = unset 176 | max_line_length = unset 177 | ", 178 | ); 179 | let path = Path::new("/").join("file.foo"); 180 | let properties = editor_config.resolve(&path); 181 | assert_eq!(properties, EditorConfigProperties::default()); 182 | } 183 | 184 | #[test] 185 | fn resolve_with_cwd() { 186 | let editor_config = EditorConfig::parse( 187 | " 188 | [*] 189 | indent_size = 2 190 | 191 | [*.ts] 192 | indent_size = 4 193 | 194 | [src/**/*.ts] 195 | indent_size = 8 196 | ", 197 | ) 198 | .with_cwd("/project"); 199 | 200 | assert_eq!(editor_config.cwd(), Some(Path::new("/project"))); 201 | 202 | // Absolute path should be resolved relative to cwd 203 | let properties = editor_config.resolve(Path::new("/project/file.ts")); 204 | assert_eq!(properties.indent_size, Value(4)); 205 | 206 | let properties = editor_config.resolve(Path::new("/project/src/file.ts")); 207 | assert_eq!(properties.indent_size, Value(8)); 208 | 209 | // Path not under cwd should still work (uses path as-is) 210 | let properties = editor_config.resolve(Path::new("/other/file.ts")); 211 | assert_eq!(properties.indent_size, Value(4)); 212 | 213 | // Relative path should work as before 214 | let properties = editor_config.resolve(Path::new("file.ts")); 215 | assert_eq!(properties.indent_size, Value(4)); 216 | } 217 | 218 | #[test] 219 | fn resolve_with_cwd_pathbuf() { 220 | let cwd = PathBuf::from("/my/project"); 221 | let editor_config = EditorConfig::parse( 222 | " 223 | [*.rs] 224 | indent_size = 4 225 | ", 226 | ) 227 | .with_cwd(&cwd); 228 | 229 | let properties = editor_config.resolve(&cwd.join("main.rs")); 230 | assert_eq!(properties.indent_size, Value(4)); 231 | } 232 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # This template contains all of the possible sections and their default values 2 | 3 | # Note that all fields that take a lint level have these possible values: 4 | # * deny - An error will be produced and the check will fail 5 | # * warn - A warning will be produced, but the check will not fail 6 | # * allow - No warning or error will be produced, though in some cases a note 7 | # will be 8 | 9 | # The values provided in this template are the default values that will be used 10 | # when any section or field is not specified in your own configuration 11 | 12 | # This section is considered when running `cargo deny check advisories` 13 | # More documentation for the advisories section can be found here: 14 | # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html 15 | [advisories] 16 | # The path where the advisory database is cloned/fetched into 17 | db-path = "~/.cargo/advisory-db" 18 | # The url(s) of the advisory databases to use 19 | db-urls = ["https://github.com/rustsec/advisory-db"] 20 | # The lint level for crates that have been yanked from their source registry 21 | yanked = "warn" 22 | # A list of advisory IDs to ignore. Note that ignored advisories will still 23 | # output a note when they are encountered. 24 | ignore = [ 25 | "RUSTSEC-2024-0399", 26 | # "RUSTSEC-0000-0000", 27 | ] 28 | # Threshold for security vulnerabilities, any vulnerability with a CVSS score 29 | # lower than the range specified will be ignored. Note that ignored advisories 30 | # will still output a note when they are encountered. 31 | # * None - CVSS Score 0.0 32 | # * Low - CVSS Score 0.1 - 3.9 33 | # * Medium - CVSS Score 4.0 - 6.9 34 | # * High - CVSS Score 7.0 - 8.9 35 | # * Critical - CVSS Score 9.0 - 10.0 36 | # severity-threshold = 37 | 38 | # If this is true, then cargo deny will use the git executable to fetch advisory database. 39 | # If this is false, then it uses a built-in git library. 40 | # Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. 41 | # See Git Authentication for more information about setting up git authentication. 42 | # git-fetch-with-cli = true 43 | 44 | # This section is considered when running `cargo deny check licenses` 45 | # More documentation for the licenses section can be found here: 46 | # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html 47 | [licenses] 48 | # List of explicitly allowed licenses 49 | # See https://spdx.org/licenses/ for list of possible licenses 50 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 51 | allow = [ 52 | "Apache-2.0", 53 | "BSD-3-Clause", 54 | "ISC", 55 | "MIT", 56 | "MPL-2.0", 57 | "OpenSSL", 58 | "Unicode-DFS-2016", 59 | "Unicode-3.0", 60 | ] 61 | # The confidence threshold for detecting a license from license text. 62 | # The higher the value, the more closely the license text must be to the 63 | # canonical license text of a valid SPDX license file. 64 | # [possible values: any between 0.0 and 1.0]. 65 | confidence-threshold = 0.8 66 | # Allow 1 or more licenses on a per-crate basis, so that particular licenses 67 | # aren't accepted for every possible crate as with the normal allow list 68 | exceptions = [ 69 | 70 | # Each entry is the crate and version constraint, and its specific allow 71 | # list 72 | # { allow = ["Zlib"], name = "adler32", version = "*" }, 73 | ] 74 | 75 | # Some crates don't have (easily) machine readable licensing information, 76 | # adding a clarification entry for it allows you to manually specify the 77 | # licensing information 78 | [[licenses.clarify]] 79 | # The name of the crate the clarification applies to 80 | name = "ring" 81 | # The optional version constraint for the crate 82 | version = "*" 83 | # The SPDX expression for the license requirements of the crate 84 | expression = "MIT AND ISC AND OpenSSL" 85 | # One or more files in the crate's source used as the "source of truth" for 86 | # the license expression. If the contents match, the clarification will be used 87 | # when running the license check, otherwise the clarification will be ignored 88 | # and the crate will be checked normally, which may produce warnings or errors 89 | # depending on the rest of your configuration 90 | license-files = [ 91 | # Each entry is a crate relative path, and the (opaque) hash of its contents 92 | { path = "LICENSE", hash = 0xbd0eed23 }, 93 | ] 94 | 95 | [licenses.private] 96 | # If true, ignores workspace crates that aren't published, or are only 97 | # published to private registries. 98 | # To see how to mark a crate as unpublished (to the official registry), 99 | # visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. 100 | ignore = false 101 | # One or more private registries that you might publish crates to, if a crate 102 | # is only published to private registries, and ignore is true, the crate will 103 | # not have its license(s) checked 104 | registries = [ 105 | 106 | # "https://sekretz.com/registry 107 | ] 108 | 109 | # This section is considered when running `cargo deny check bans`. 110 | # More documentation about the 'bans' section can be found here: 111 | # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html 112 | [bans] 113 | # Lint level for when multiple versions of the same crate are detected 114 | multiple-versions = "warn" 115 | # Lint level for when a crate version requirement is `*` 116 | wildcards = "allow" 117 | # The graph highlighting used when creating dotgraphs for crates 118 | # with multiple versions 119 | # * lowest-version - The path to the lowest versioned duplicate is highlighted 120 | # * simplest-path - The path to the version with the fewest edges is highlighted 121 | # * all - Both lowest-version and simplest-path are used 122 | highlight = "all" 123 | # The default lint level for `default` features for crates that are members of 124 | # the workspace that is being checked. This can be overridden by allowing/denying 125 | # `default` on a crate-by-crate basis if desired. 126 | workspace-default-features = "allow" 127 | # The default lint level for `default` features for external crates that are not 128 | # members of the workspace. This can be overridden by allowing/denying `default` 129 | # on a crate-by-crate basis if desired. 130 | external-default-features = "allow" 131 | # List of crates that are allowed. Use with care! 132 | allow = [ 133 | 134 | # { name = "ansi_term", version = "=0.11.0" }, 135 | ] 136 | # List of crates to deny 137 | deny = [ 138 | 139 | # Each entry the name of a crate and a version range. If version is 140 | # not specified, all versions will be matched. 141 | # { name = "ansi_term", version = "=0.11.0" }, 142 | # 143 | # Wrapper crates can optionally be specified to allow the crate when it 144 | # is a direct dependency of the otherwise banned crate 145 | # { name = "ansi_term", version = "=0.11.0", wrappers = [] }, 146 | ] 147 | 148 | # List of features to allow/deny 149 | # Each entry the name of a crate and a version range. If version is 150 | # not specified, all versions will be matched. 151 | # [[bans.features]] 152 | # name = "reqwest" 153 | # Features to not allow 154 | # deny = ["json"] 155 | # Features to allow 156 | # allow = [ 157 | # "rustls", 158 | # "__rustls", 159 | # "__tls", 160 | # "hyper-rustls", 161 | # "rustls", 162 | # "rustls-pemfile", 163 | # "rustls-tls-webpki-roots", 164 | # "tokio-rustls", 165 | # "webpki-roots", 166 | # ] 167 | # If true, the allowed features must exactly match the enabled feature set. If 168 | # this is set there is no point setting `deny` 169 | # exact = true 170 | 171 | # Certain crates/versions that will be skipped when doing duplicate detection. 172 | skip = [ 173 | 174 | # { name = "ansi_term", version = "=0.11.0" }, 175 | ] 176 | # Similarly to `skip` allows you to skip certain crates during duplicate 177 | # detection. Unlike skip, it also includes the entire tree of transitive 178 | # dependencies starting at the specified crate, up to a certain depth, which is 179 | # by default infinite. 180 | skip-tree = [ 181 | 182 | # { name = "ansi_term", version = "=0.11.0", depth = 20 }, 183 | ] 184 | 185 | # This section is considered when running `cargo deny check sources`. 186 | # More documentation about the 'sources' section can be found here: 187 | # https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html 188 | [sources] 189 | # Lint level for what to happen when a crate from a crate registry that is not 190 | # in the allow list is encountered 191 | unknown-registry = "warn" 192 | # Lint level for what to happen when a crate from a git repository that is not 193 | # in the allow list is encountered 194 | unknown-git = "warn" 195 | # List of URLs for allowed crate registries. Defaults to the crates.io index 196 | # if not specified. If it is specified but empty, no registries are allowed. 197 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 198 | # List of URLs for allowed Git repositories 199 | allow-git = [] 200 | 201 | [sources.allow-org] 202 | # 1 or more github.com organizations to allow git sources for 203 | # github = [""] 204 | # 1 or more gitlab.com organizations to allow git sources for 205 | # gitlab = [""] 206 | # 1 or more bitbucket.org organizations to allow git sources for 207 | # bitbucket = [""] 208 | 209 | [graph] 210 | # If 1 or more target triples (and optionally, target_features) are specified, 211 | # only the specified targets will be checked when running `cargo deny check`. 212 | # This means, if a particular package is only ever used as a target specific 213 | # dependency, such as, for example, the `nix` crate only being used via the 214 | # `target_family = "unix"` configuration, that only having windows targets in 215 | # this list would mean the nix crate, as well as any of its exclusive 216 | # dependencies not shared by any other crates, would be ignored, as the target 217 | # list here is effectively saying which targets you are building for. 218 | targets = [ 219 | 220 | # The triple can be any string, but only the target triples built in to 221 | # rustc (as of 1.40) can be checked against actual config expressions 222 | # { triple = "x86_64-unknown-linux-musl" }, 223 | # You can also specify which target_features you promise are enabled for a 224 | # particular target. target_features are currently not validated against 225 | # the actual valid features supported by the target architecture. 226 | # { triple = "wasm32-unknown-unknown", features = ["atomics"] }, 227 | ] 228 | # When creating the dependency graph used as the source of truth when checks are 229 | # executed, this field can be used to prune crates from the graph, removing them 230 | # from the view of cargo-deny. This is an extremely heavy hammer, as if a crate 231 | # is pruned from the graph, all of its dependencies will also be pruned unless 232 | # they are connected to another crate in the graph that hasn't been pruned, 233 | # so it should be used with care. The identifiers are [Package ID Specifications] 234 | # (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) 235 | # exclude = [] 236 | # If true, metadata will be collected with `--all-features`. Note that this can't 237 | # be toggled off if true, if you want to conditionally enable `--all-features` it 238 | # is recommended to pass `--all-features` on the cmd line instead 239 | all-features = false 240 | # If true, metadata will be collected with `--no-default-features`. The same 241 | # caveat with `all-features` applies 242 | no-default-features = false 243 | 244 | # If set, these feature will be enabled when collecting metadata. If `--features` 245 | # is specified on the cmd line they will take precedence over this option. 246 | # features = [] 247 | 248 | [output] 249 | # When outputting inclusion graphs in diagnostics that include features, this 250 | # option can be used to specify the depth at which feature edges will be added. 251 | # This option is included since the graphs can be quite large and the addition 252 | # of features from the crate(s) to all of the graph roots can be far too verbose. 253 | # This option can be overridden via `--feature-depth` on the cmd line 254 | feature-depth = 1 255 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use globset::{Glob, GlobMatcher}; 4 | 5 | #[derive(Debug, Default, Clone)] 6 | pub struct EditorConfig { 7 | /// Set to true to tell the core not to check any higher directory for EditorConfig settings for on the current filename. 8 | root: bool, 9 | 10 | sections: Vec, 11 | 12 | /// The base directory for resolving absolute paths. 13 | cwd: Option, 14 | } 15 | 16 | impl EditorConfig { 17 | pub fn root(&self) -> bool { 18 | self.root 19 | } 20 | 21 | pub fn sections(&self) -> &[EditorConfigSection] { 22 | &self.sections 23 | } 24 | 25 | pub fn cwd(&self) -> Option<&Path> { 26 | self.cwd.as_deref() 27 | } 28 | 29 | /// Sets the current working directory for resolving absolute paths. 30 | pub fn with_cwd>(mut self, cwd: P) -> Self { 31 | self.cwd = Some(cwd.as_ref().to_path_buf()); 32 | self 33 | } 34 | } 35 | 36 | /// 37 | #[derive(Debug, Default, Clone)] 38 | pub struct EditorConfigSection { 39 | /// Section Name: the string between the beginning `[` and the ending `]`. 40 | pub name: String, 41 | 42 | pub matcher: Option, 43 | 44 | pub properties: EditorConfigProperties, 45 | } 46 | 47 | #[derive(Debug, Default, Clone, Eq, PartialEq)] 48 | pub enum EditorConfigProperty { 49 | #[default] 50 | None, 51 | Unset, 52 | Value(T), 53 | } 54 | 55 | #[derive(Debug, Default, Clone, Eq, PartialEq)] 56 | pub struct EditorConfigProperties { 57 | /// Set to tab or space to use tabs or spaces for indentation, respectively. 58 | /// Option tab implies that an indentation is to be filled by as many hard tabs as possible, with the rest of the indentation filled by spaces. 59 | /// A non-normative explanation can be found in the indentation_ section. 60 | /// The values are case-insensitive. 61 | pub indent_style: EditorConfigProperty, 62 | 63 | /// Set to a whole number defining the number of columns used for each indentation level and the width of soft tabs (when supported). 64 | /// If this equals tab, the indent_size shall be set to the tab size, which should be tab_width (if specified); else, the tab size set by the editor. 65 | /// The values are case-insensitive. 66 | pub indent_size: EditorConfigProperty, 67 | 68 | /// Set to a whole number defining the number of columns used to represent a tab character. 69 | /// This defaults to the value of indent_size and should not usually need to be specified. 70 | pub tab_width: EditorConfigProperty, 71 | 72 | /// Set to lf, cr, or crlf to control how line breaks are represented. 73 | /// The values are case-insensitive. 74 | pub end_of_line: EditorConfigProperty, 75 | 76 | /// Set to latin1, utf-8, utf-8-bom, utf-16be or utf-16le to control the character set. 77 | /// Use of utf-8-bom is discouraged. 78 | /// The values are case-insensitive. 79 | pub charset: EditorConfigProperty, 80 | 81 | /// Set to true to remove all whitespace characters preceding newline characters in the file and false to ensure it doesn’t. 82 | pub trim_trailing_whitespace: EditorConfigProperty, 83 | 84 | /// Set to true ensure file ends with a newline when saving and false to ensure it doesn’t. 85 | /// Editors must not insert newlines in empty files when saving those files, even if insert_final_newline = true. 86 | pub insert_final_newline: EditorConfigProperty, 87 | 88 | /// Prettier print width. 89 | /// Not part of spec 90 | /// But documented in 91 | pub max_line_length: EditorConfigProperty, 92 | } 93 | 94 | #[derive(Debug, Clone, Copy, Eq, PartialEq)] 95 | pub enum MaxLineLength { 96 | /// A numeric line length limit 97 | Number(usize), 98 | /// Line length limit is disabled 99 | Off, 100 | } 101 | 102 | #[derive(Debug, Clone, Copy, Eq, PartialEq)] 103 | pub enum IndentStyle { 104 | Tab, 105 | Space, 106 | } 107 | 108 | #[derive(Debug, Clone, Copy, Eq, PartialEq)] 109 | pub enum EndOfLine { 110 | Lf, 111 | Cr, 112 | Crlf, 113 | } 114 | 115 | #[derive(Debug, Clone, Copy, Eq, PartialEq)] 116 | pub enum Charset { 117 | Latin1, 118 | Utf8, 119 | Utf8bom, 120 | Utf16be, 121 | Utf16le, 122 | } 123 | 124 | impl EditorConfig { 125 | /// 126 | pub fn parse(source_text: &str) -> Self { 127 | // EditorConfig files are in an INI-like file format. 128 | // To read an EditorConfig file, take one line at a time, from beginning to end. 129 | // For each line: 130 | // 1. Remove all leading and trailing whitespace. 131 | // 2. Process the remaining text as specified for its type below. 132 | let mut root = false; 133 | let mut sections = vec![]; 134 | let mut preamble = true; 135 | for line in source_text.lines() { 136 | let line = line.trim(); 137 | // Blank: Contains nothing. Blank lines are ignored. 138 | if line.is_empty() { 139 | continue; 140 | } 141 | // Comment: starts with a ; or a #. Comment lines are ignored. 142 | if line.starts_with([';', '#']) { 143 | continue; 144 | } 145 | // Parse `root`. Must be specified in the preamble. The value is case-insensitive. 146 | if preamble 147 | && !line.starts_with('[') 148 | && let Some((key, value)) = line.split_once('=') 149 | && key.trim_end() == "root" 150 | && value.trim_start().eq_ignore_ascii_case("true") 151 | { 152 | root = true; 153 | } 154 | // Section Header: starts with a [ and ends with a ]. These lines define globs; 155 | if let Some(line) = line.strip_prefix('[') { 156 | preamble = false; 157 | if let Some(line) = line.strip_suffix(']') { 158 | let name = line.to_string(); 159 | let matcher = Glob::new(&name).ok().map(|glob| glob.compile_matcher()); 160 | sections.push(EditorConfigSection { 161 | name, 162 | matcher, 163 | ..EditorConfigSection::default() 164 | }); 165 | } 166 | } 167 | // Key-Value Pair (or Pair): contains a key and a value, separated by an `=`. 168 | if let Some(section) = sections.last_mut() 169 | && let Some((key, value)) = line.split_once('=') 170 | { 171 | let value = value.trim_start(); 172 | let properties = &mut section.properties; 173 | match key.trim_end() { 174 | "indent_style" => { 175 | properties.indent_style = IndentStyle::parse(value); 176 | } 177 | "indent_size" => { 178 | properties.indent_size = EditorConfigProperty::::parse(value); 179 | } 180 | "tab_width" => { 181 | properties.tab_width = EditorConfigProperty::::parse(value); 182 | } 183 | "end_of_line" => { 184 | properties.end_of_line = EditorConfigProperty::::parse(value); 185 | } 186 | "charset" => { 187 | properties.charset = EditorConfigProperty::::parse(value); 188 | } 189 | "trim_trailing_whitespace" => { 190 | properties.trim_trailing_whitespace = 191 | EditorConfigProperty::::parse(value); 192 | } 193 | "insert_final_newline" => { 194 | properties.insert_final_newline = 195 | EditorConfigProperty::::parse(value); 196 | } 197 | "max_line_length" => { 198 | properties.max_line_length = 199 | EditorConfigProperty::::parse(value); 200 | } 201 | _ => {} 202 | } 203 | } 204 | } 205 | 206 | Self { root, sections, cwd: None } 207 | } 208 | 209 | /// Resolve a given path and return the resolved properties. 210 | /// If `cwd` is set, absolute paths will be resolved relative to `cwd`. 211 | pub fn resolve(&self, path: &Path) -> EditorConfigProperties { 212 | let path = 213 | if let Some(cwd) = &self.cwd { path.strip_prefix(cwd).unwrap_or(path) } else { path }; 214 | let mut properties = EditorConfigProperties::default(); 215 | for section in &self.sections { 216 | if section.matcher.as_ref().is_some_and(|matcher| matcher.is_match(path)) { 217 | properties.override_with(§ion.properties); 218 | } 219 | } 220 | properties 221 | } 222 | } 223 | 224 | impl EditorConfigProperty { 225 | fn override_with(&mut self, other: &Self) { 226 | match other { 227 | Self::Value(value) => { 228 | *self = Self::Value(*value); 229 | } 230 | Self::Unset => { 231 | *self = Self::None; 232 | } 233 | Self::None => {} 234 | } 235 | } 236 | } 237 | 238 | impl EditorConfigProperties { 239 | fn override_with(&mut self, other: &Self) { 240 | self.indent_style.override_with(&other.indent_style); 241 | self.indent_size.override_with(&other.indent_size); 242 | self.tab_width.override_with(&other.tab_width); 243 | self.end_of_line.override_with(&other.end_of_line); 244 | self.charset.override_with(&other.charset); 245 | self.trim_trailing_whitespace.override_with(&other.trim_trailing_whitespace); 246 | self.insert_final_newline.override_with(&other.insert_final_newline); 247 | self.max_line_length.override_with(&other.max_line_length); 248 | } 249 | } 250 | 251 | impl EditorConfigProperty { 252 | fn parse(s: &str) -> Self { 253 | if s.eq_ignore_ascii_case("unset") { 254 | Self::Unset 255 | } else { 256 | s.parse::().map_or(Self::None, EditorConfigProperty::Value) 257 | } 258 | } 259 | } 260 | 261 | impl EditorConfigProperty { 262 | fn parse(s: &str) -> Self { 263 | if s.eq_ignore_ascii_case("true") { 264 | EditorConfigProperty::Value(true) 265 | } else if s.eq_ignore_ascii_case("false") { 266 | EditorConfigProperty::Value(false) 267 | } else if s.eq_ignore_ascii_case("unset") { 268 | EditorConfigProperty::Unset 269 | } else { 270 | EditorConfigProperty::None 271 | } 272 | } 273 | } 274 | 275 | impl IndentStyle { 276 | fn parse(s: &str) -> EditorConfigProperty { 277 | if s.eq_ignore_ascii_case("tab") { 278 | EditorConfigProperty::Value(Self::Tab) 279 | } else if s.eq_ignore_ascii_case("space") { 280 | EditorConfigProperty::Value(Self::Space) 281 | } else if s.eq_ignore_ascii_case("unset") { 282 | EditorConfigProperty::Unset 283 | } else { 284 | EditorConfigProperty::None 285 | } 286 | } 287 | } 288 | 289 | impl EditorConfigProperty { 290 | fn parse(s: &str) -> Self { 291 | if s.eq_ignore_ascii_case("lf") { 292 | Self::Value(EndOfLine::Lf) 293 | } else if s.eq_ignore_ascii_case("cr") { 294 | Self::Value(EndOfLine::Cr) 295 | } else if s.eq_ignore_ascii_case("crlf") { 296 | Self::Value(EndOfLine::Crlf) 297 | } else if s.eq_ignore_ascii_case("unset") { 298 | Self::Unset 299 | } else { 300 | Self::None 301 | } 302 | } 303 | } 304 | 305 | impl EditorConfigProperty { 306 | fn parse(s: &str) -> Self { 307 | if s.eq_ignore_ascii_case("utf-8") { 308 | Self::Value(Charset::Utf8) 309 | } else if s.eq_ignore_ascii_case("latin1") { 310 | Self::Value(Charset::Latin1) 311 | } else if s.eq_ignore_ascii_case("utf-16be") { 312 | Self::Value(Charset::Utf16be) 313 | } else if s.eq_ignore_ascii_case("utf-16le") { 314 | Self::Value(Charset::Utf16le) 315 | } else if s.eq_ignore_ascii_case("utf-8-bom") { 316 | Self::Value(Charset::Utf8bom) 317 | } else if s.eq_ignore_ascii_case("unset") { 318 | Self::Unset 319 | } else { 320 | Self::None 321 | } 322 | } 323 | } 324 | 325 | impl EditorConfigProperty { 326 | fn parse(s: &str) -> Self { 327 | if s.eq_ignore_ascii_case("off") { 328 | Self::Value(MaxLineLength::Off) 329 | } else if s.eq_ignore_ascii_case("unset") { 330 | Self::Unset 331 | } else if let Ok(n) = s.parse::() { 332 | Self::Value(MaxLineLength::Number(n)) 333 | } else { 334 | Self::None 335 | } 336 | } 337 | } 338 | --------------------------------------------------------------------------------