diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index f53610d..50378dd 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -1,27 +1,27 @@ -name: Bump Cargo Version +name: Release on Main on: - release: - types: - - published + push: + branches: + - main permissions: contents: write concurrency: - group: bump-version-${{ github.event.release.tag_name }} + group: release-main cancel-in-progress: false jobs: - bump-version: - name: Bump Cargo.toml for next release + release: + name: Bump version and create release + if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: - - name: Checkout default branch + - name: Checkout main branch uses: actions/checkout@v4 with: - ref: ${{ github.event.repository.default_branch }} fetch-depth: 0 fetch-tags: true @@ -33,17 +33,19 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Determine bump level and next version + - name: Determine release version id: version - env: - RELEASE_TAG: ${{ github.event.release.tag_name }} run: | python - <<'PY' import os import re import subprocess + import tomllib + from pathlib import Path + SEMVER_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)") SEMVER_TAG_RE = re.compile(r"v(\d+)\.(\d+)\.(\d+)") + TYPE_PREFIX_RE = re.compile(r"^(?Pfeat|fix|chore|docs)(?:\([^)]+\))?!?:", re.IGNORECASE) def git_lines(*args: str) -> list[str]: @@ -59,12 +61,15 @@ jobs: return [line.strip() for line in output.splitlines() if line.strip()] - release_tag = os.environ["RELEASE_TAG"].strip() - match = SEMVER_TAG_RE.fullmatch(release_tag) - if not match: - raise SystemExit(f"Release tag '{release_tag}' is not in vMAJOR.MINOR.PATCH format") + def parse_semver(value: str) -> tuple[int, int, int]: + match = SEMVER_RE.fullmatch(value.strip()) + if not match: + raise ValueError(f"Invalid semver: {value}") + return tuple(map(int, match.groups())) + - release_version = tuple(map(int, match.groups())) + latest_tag = "" + latest_tag_semver = (0, 0, 0) tag_versions = [] for existing_tag in git_lines("tag", "--list", "v*"): @@ -72,56 +77,70 @@ jobs: if tag_match: tag_versions.append((tuple(map(int, tag_match.groups())), existing_tag)) - if not any(existing_tag == release_tag for _, existing_tag in tag_versions): - tag_versions.append((release_version, release_tag)) - - tag_versions.sort() - previous_tag = "" - for version, existing_tag in tag_versions: - if version < release_version: - previous_tag = existing_tag - elif version == release_version and existing_tag == release_tag: - break - - if previous_tag: - commit_range = f"{previous_tag}..{release_tag}" - else: - commit_range = release_tag + if tag_versions: + tag_versions.sort() + latest_tag_semver, latest_tag = tag_versions[-1] + commit_range = f"{latest_tag}..HEAD" if latest_tag else "HEAD" subjects = git_lines("log", "--format=%s", commit_range) - type_prefix = re.compile(r"^(?P[a-z]+)(?:\([^)]+\))?!?:") bump_level = "patch" for subject in subjects: - prefix_match = type_prefix.match(subject.lower()) + prefix_match = TYPE_PREFIX_RE.match(subject) if not prefix_match: continue - commit_type = prefix_match.group("type") + + commit_type = prefix_match.group("type").lower() if commit_type == "feat": bump_level = "minor" break - if commit_type in {"chore", "docs"}: + + if commit_type in {"fix", "chore", "docs"}: bump_level = "patch" - major, minor, patch = release_version + major, minor, patch = latest_tag_semver if bump_level == "minor": - next_version = f"{major}.{minor + 1}.0" + calculated_version = f"{major}.{minor + 1}.0" + else: + calculated_version = f"{major}.{minor}.{patch + 1}" + + cargo_toml_path = Path("Cargo.toml") + cargo_toml_text = cargo_toml_path.read_text(encoding="utf-8") + current_version = tomllib.loads(cargo_toml_text)["package"]["version"] + + current_semver = parse_semver(current_version) + calculated_semver = parse_semver(calculated_version) + + if current_semver >= calculated_semver: + release_version = current_version else: - next_version = f"{major}.{minor}.{patch + 1}" + release_version = calculated_version + release_semver = parse_semver(release_version) + update_needed = current_semver < release_semver + release_tag = f"v{release_version}" + + print(f"Latest semver tag: {latest_tag or ''}") print(f"Analyzed {len(subjects)} commit message(s) in '{commit_range}'") print(f"Selected bump level: {bump_level}") - print(f"Next version: {next_version}") + print(f"Calculated version: {calculated_version}") + print(f"Cargo.toml version: {current_version}") + print(f"Release version: {release_version}") + print(f"Will update Cargo files: {update_needed}") with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: - output.write(f"bump_level={bump_level}\n") - output.write(f"next_version={next_version}\n") + output.write(f"latest_tag={latest_tag}\n") output.write(f"commit_range={commit_range}\n") + output.write(f"bump_level={bump_level}\n") + output.write(f"release_version={release_version}\n") + output.write(f"release_tag={release_tag}\n") + output.write(f"update_needed={'true' if update_needed else 'false'}\n") PY - name: Update project version files + if: steps.version.outputs.update_needed == 'true' env: - NEXT_VERSION: ${{ steps.version.outputs.next_version }} + RELEASE_VERSION: ${{ steps.version.outputs.release_version }} run: | python - <<'PY' import os @@ -129,14 +148,16 @@ jobs: import tomllib from pathlib import Path + def parse_semver(value: str) -> tuple[int, int, int]: match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", value.strip()) if not match: raise ValueError(value) return tuple(map(int, match.groups())) - next_version = os.environ["NEXT_VERSION"] - next_semver = parse_semver(next_version) + + release_version = os.environ["RELEASE_VERSION"] + release_semver = parse_semver(release_version) cargo_toml_path = Path("Cargo.toml") cargo_toml_text = cargo_toml_path.read_text(encoding="utf-8") @@ -144,7 +165,7 @@ jobs: package_name = package["name"] current_version = package["version"] - if parse_semver(current_version) >= next_semver: + if parse_semver(current_version) >= release_semver: print(f"Cargo.toml already at {current_version}; no bump needed") raise SystemExit(0) @@ -160,7 +181,7 @@ jobs: if in_package and stripped.startswith("version"): indent = line[: len(line) - len(line.lstrip())] - lines[index] = f'{indent}version = "{next_version}"' + lines[index] = f'{indent}version = "{release_version}"' updated_toml = True break @@ -189,7 +210,7 @@ jobs: continue if in_package_block and matches_name and stripped.startswith("version = "): - lock_lines[index] = f'version = "{next_version}"' + lock_lines[index] = f'version = "{release_version}"' updated_lock = True break @@ -198,11 +219,10 @@ jobs: PY - name: Commit and push version bump + if: steps.version.outputs.update_needed == 'true' env: - NEXT_VERSION: ${{ steps.version.outputs.next_version }} + RELEASE_VERSION: ${{ steps.version.outputs.release_version }} BUMP_LEVEL: ${{ steps.version.outputs.bump_level }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail @@ -217,5 +237,33 @@ jobs: fi git add "${paths[@]}" - git commit -m "chore: bump version to ${NEXT_VERSION} (${BUMP_LEVEL}) after ${RELEASE_TAG}" - git push origin "HEAD:${DEFAULT_BRANCH}" + git commit -m "chore: bump version to ${RELEASE_VERSION} (${BUMP_LEVEL}) [skip release]" + git push origin "HEAD:main" + + - name: Create and push release tag + env: + RELEASE_TAG: ${{ steps.version.outputs.release_tag }} + run: | + set -euo pipefail + + if git ls-remote --exit-code --tags origin "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then + echo "Tag ${RELEASE_TAG} already exists on origin" + exit 0 + fi + + git tag -a "${RELEASE_TAG}" -m "Release ${RELEASE_TAG}" + git push origin "${RELEASE_TAG}" + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.version.outputs.release_tag }} + run: | + set -euo pipefail + + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then + echo "Release ${RELEASE_TAG} already exists" + exit 0 + fi + + gh release create "${RELEASE_TAG}" --title "${RELEASE_TAG}" --generate-notes diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..8265dd1 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,54 @@ +name: PR Checks + +on: + pull_request: + +permissions: + contents: read + +jobs: + title-check: + name: PR title check + runs-on: ubuntu-latest + steps: + - name: Validate PR title prefix + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + set -euo pipefail + + if [[ ! "$PR_TITLE" =~ ^(fix|chore|docs|feat): ]]; then + echo "PR title must start with one of: fix:, chore:, docs:, feat:" + echo "Current title: $PR_TITLE" + exit 1 + fi + + checks: + name: ${{ matrix.name }} + runs-on: ubuntu-latest + needs: title-check + strategy: + fail-fast: false + matrix: + include: + - name: cargo fmt + command: cargo fmt --all -- --check + - name: cargo test + command: cargo test + - name: cargo build + command: cargo build + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@v2 + + - name: Run ${{ matrix.name }} + run: ${{ matrix.command }} diff --git a/README.md b/README.md index 571d240..ab6321f 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,19 @@ Theme selection: - Use `--theme auto|dark|light` to control rendering for your terminal. - `--theme` takes precedence over `DEFF_THEME=dark|light`. +Custom syntax grammars: + +- `deff` loads syntect defaults plus any extra `.sublime-syntax` files found in: + - `assets/syntaxes` (current working directory) + - `.deff/syntaxes` (current working directory) + - `DEFF_SYNTAX_DIR` + - `DEFF_SYNTAX_PATHS` (path list, colon-separated on macOS/Linux) +- Example: + + ```bash + DEFF_SYNTAX_DIR="$HOME/.config/deff/syntaxes" deff + ``` + Search and reviewed workflow: - Press `/` to enter a search query for the current file (searches both panes). diff --git a/assets/syntaxes/Dotenv.sublime-syntax b/assets/syntaxes/Dotenv.sublime-syntax new file mode 100644 index 0000000..f6ad310 --- /dev/null +++ b/assets/syntaxes/Dotenv.sublime-syntax @@ -0,0 +1,22 @@ +%YAML 1.2 +--- +name: Dotenv (deff) +file_extensions: + - env +scope: source.dotenv +contexts: + main: + - match: '^\s*#.*$' + scope: comment.line.number-sign.dotenv + - match: '^\s*(export)\b' + scope: keyword.control.dotenv + - match: '^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(=)' + captures: + 1: variable.other.constant.dotenv + 2: keyword.operator.assignment.dotenv + - match: '"(?:[^"\\]|\\.)*"' + scope: string.quoted.double.dotenv + - match: "'[^']*'" + scope: string.quoted.single.dotenv + - match: '\$\{?[A-Za-z_][A-Za-z0-9_]*\}?' + scope: variable.other.readwrite.dotenv diff --git a/assets/syntaxes/Handlebars.sublime-syntax b/assets/syntaxes/Handlebars.sublime-syntax new file mode 100644 index 0000000..24e98cb --- /dev/null +++ b/assets/syntaxes/Handlebars.sublime-syntax @@ -0,0 +1,57 @@ +%YAML 1.2 +--- +name: Handlebars (deff) +file_extensions: + - hbs + - handlebars +scope: text.html.handlebars +contexts: + main: + - include: html-tags + - include: html-attributes + - include: html-strings + - include: handlebars-tags + + handlebars-tags: + - match: '{{!--.*?--}}' + scope: comment.block.handlebars + - match: '{{!.*?}}' + scope: comment.line.handlebars + - match: '(\{\{\{)([#/^>]?\s*)([A-Za-z_][A-Za-z0-9_.-]*)' + captures: + 1: punctuation.section.embedded.handlebars + 2: keyword.control.handlebars + 3: keyword.control.handlebars + - match: '(\{\{)([#/^>]?\s*)([A-Za-z_][A-Za-z0-9_.-]*)' + captures: + 1: punctuation.section.embedded.handlebars + 2: keyword.control.handlebars + 3: keyword.control.handlebars + - match: '}}}' + scope: punctuation.section.embedded.handlebars + - match: '}}' + scope: punctuation.section.embedded.handlebars + - match: '{{{' + scope: punctuation.section.embedded.handlebars + - match: '{{' + scope: punctuation.section.embedded.handlebars + + html-tags: + - match: '(' + scope: punctuation.definition.tag.end.html + + html-attributes: + - match: '\b([A-Za-z_:][-A-Za-z0-9_:.]*)(=)' + captures: + 1: entity.other.attribute-name.html + 2: punctuation.separator.key-value.html + + html-strings: + - match: '"[^"]*"' + scope: string.quoted.double.html + - match: "'[^']*'" + scope: string.quoted.single.html diff --git a/assets/syntaxes/JSX.sublime-syntax b/assets/syntaxes/JSX.sublime-syntax new file mode 100644 index 0000000..eae2421 --- /dev/null +++ b/assets/syntaxes/JSX.sublime-syntax @@ -0,0 +1,27 @@ +%YAML 1.2 +--- +name: JSX (deff) +file_extensions: + - jsx +scope: source.jsx +contexts: + main: + - match: '//.*$' + scope: comment.line.double-slash.jsx + - match: '"[^"]*"' + scope: string.quoted.double.jsx + - match: "'[^']*'" + scope: string.quoted.single.jsx + - match: '(' + scope: punctuation.definition.tag.end.jsx + - match: '\b(?:import|export|from|as|const|let|var|function|class|new|return|if|else|for|while|switch|case|default|try|catch|finally|throw|await|async)\b' + scope: keyword.control.jsx + - match: '\b(?:true|false|null|undefined)\b' + scope: constant.language.jsx + - match: '\b(?!if\b|for\b|while\b|switch\b|catch\b|function\b|return\b|typeof\b|new\b|delete\b|void\b|await\b|import\b|export\b|class\b)([A-Za-z_$][A-Za-z0-9_$]*)\s*(?=\()' + captures: + 1: variable.function.jsx diff --git a/assets/syntaxes/Kotlin.sublime-syntax b/assets/syntaxes/Kotlin.sublime-syntax new file mode 100644 index 0000000..c8ac9a4 --- /dev/null +++ b/assets/syntaxes/Kotlin.sublime-syntax @@ -0,0 +1,21 @@ +%YAML 1.2 +--- +name: Kotlin (deff) +file_extensions: + - kt + - kts +scope: source.kotlin +contexts: + main: + - match: '//.*$' + scope: comment.line.double-slash.kotlin + - match: '"[^"]*"' + scope: string.quoted.double.kotlin + - match: "'[^']*'" + scope: string.quoted.single.kotlin + - match: '\b(?:package|import|class|interface|object|fun|val|var|when|is|in|if|else|for|while|return|try|catch|finally|throw)\b' + scope: keyword.control.kotlin + - match: '\b(?:Int|Long|Float|Double|Boolean|String|Unit|Any|Nothing)\b' + scope: storage.type.kotlin + - match: '\b(?:true|false|null)\b' + scope: constant.language.kotlin diff --git a/assets/syntaxes/README.md b/assets/syntaxes/README.md new file mode 100644 index 0000000..3868cc5 --- /dev/null +++ b/assets/syntaxes/README.md @@ -0,0 +1,12 @@ +This directory contains extra `.sublime-syntax` grammars loaded by deff at startup. + +You can add more grammar files here to extend language coverage further. + +deff loads custom syntaxes from these locations (if present): + +- `assets/syntaxes` (relative to the current working directory) +- `.deff/syntaxes` (relative to the current working directory) +- `DEFF_SYNTAX_DIR` +- `DEFF_SYNTAX_PATHS` (OS path list, e.g. colon-separated on macOS/Linux) + +Syntactic detection still uses syntect APIs first, then first-line/shebang fallback. diff --git a/assets/syntaxes/TSX.sublime-syntax b/assets/syntaxes/TSX.sublime-syntax new file mode 100644 index 0000000..676ddc8 --- /dev/null +++ b/assets/syntaxes/TSX.sublime-syntax @@ -0,0 +1,105 @@ +%YAML 1.2 +--- +name: TSX (deff) +file_extensions: + - tsx +scope: source.tsx +contexts: + main: + - include: comments + - include: strings + - include: jsx-tags + - include: function-definitions + - include: keywords + - include: types + - include: literals + - include: function-calls + + comments: + - match: '//.*$' + scope: comment.line.double-slash.tsx + - match: '/\*' + scope: punctuation.definition.comment.begin.tsx + push: + - meta_scope: comment.block.tsx + - match: '\*/' + scope: punctuation.definition.comment.end.tsx + pop: true + + strings: + - match: '`(?:[^`\\]|\\.)*`' + scope: string.quoted.template.tsx + - match: '"[^"]*"' + scope: string.quoted.double.tsx + - match: "'[^']*'" + scope: string.quoted.single.tsx + + jsx-tags: + - match: '(' + scope: punctuation.definition.tag.end.tsx + + function-definitions: + - match: '\b(async)\s+(function)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(\()' + captures: + 1: keyword.control.tsx + 2: keyword.control.tsx + 3: entity.name.function.tsx + 4: punctuation.section.parameters.begin.tsx + push: function-parameters + - match: '\b(function)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(\()' + captures: + 1: keyword.control.tsx + 2: entity.name.function.tsx + 3: punctuation.section.parameters.begin.tsx + push: function-parameters + - match: '\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?(?:<[^>]+>\s*)?(\()' + captures: + 1: entity.name.function.tsx + 2: punctuation.section.parameters.begin.tsx + push: function-parameters + - match: '\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?([A-Za-z_$][A-Za-z0-9_$]*)\s*(=>)' + captures: + 1: entity.name.function.tsx + 2: variable.parameter.tsx + 3: keyword.operator.arrow.tsx + + function-parameters: + - meta_scope: meta.parameters.tsx + - include: comments + - include: strings + - match: '(\.\.\.)?([A-Za-z_$][A-Za-z0-9_$]*)(\??)(?=\s*(?::|,|\)|=))' + captures: + 1: keyword.operator.spread.tsx + 2: variable.parameter.tsx + 3: keyword.operator.optional.tsx + - match: '\b([A-Za-z_$][A-Za-z0-9_$]*)\b(?=\s*(?:,|}|=))' + captures: + 1: variable.parameter.tsx + - match: ':' + scope: punctuation.separator.type.tsx + - match: ',' + scope: punctuation.separator.comma.tsx + - match: '\)' + scope: punctuation.section.parameters.end.tsx + pop: true + + keywords: + - match: '\b(?:import|export|from|as|const|let|var|function|class|interface|type|extends|implements|new|return|if|else|for|while|switch|case|default|try|catch|finally|throw|await|async)\b' + scope: keyword.control.tsx + + types: + - match: '\b(?:string|number|boolean|void|unknown|never|any)\b' + scope: storage.type.tsx + + literals: + - match: '\b(?:true|false|null|undefined)\b' + scope: constant.language.tsx + + function-calls: + - match: '\b(?!if\b|for\b|while\b|switch\b|catch\b|function\b|return\b|typeof\b|new\b|delete\b|void\b|await\b|import\b|export\b|class\b)([A-Za-z_$][A-Za-z0-9_$]*)\s*(?=\()' + captures: + 1: variable.function.tsx diff --git a/assets/syntaxes/TypeScript.sublime-syntax b/assets/syntaxes/TypeScript.sublime-syntax new file mode 100644 index 0000000..e037508 --- /dev/null +++ b/assets/syntaxes/TypeScript.sublime-syntax @@ -0,0 +1,96 @@ +%YAML 1.2 +--- +name: TypeScript (deff) +file_extensions: + - ts +scope: source.ts +contexts: + main: + - include: comments + - include: strings + - include: function-definitions + - include: keywords + - include: types + - include: literals + - include: function-calls + + comments: + - match: '//.*$' + scope: comment.line.double-slash.ts + - match: '/\*' + scope: punctuation.definition.comment.begin.ts + push: + - meta_scope: comment.block.ts + - match: '\*/' + scope: punctuation.definition.comment.end.ts + pop: true + + strings: + - match: '`(?:[^`\\]|\\.)*`' + scope: string.quoted.template.ts + - match: '"[^"]*"' + scope: string.quoted.double.ts + - match: "'[^']*'" + scope: string.quoted.single.ts + + function-definitions: + - match: '\b(async)\s+(function)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(\()' + captures: + 1: keyword.control.ts + 2: keyword.control.ts + 3: entity.name.function.ts + 4: punctuation.section.parameters.begin.ts + push: function-parameters + - match: '\b(function)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(\()' + captures: + 1: keyword.control.ts + 2: entity.name.function.ts + 3: punctuation.section.parameters.begin.ts + push: function-parameters + - match: '\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?(?:<[^>]+>\s*)?(\()' + captures: + 1: entity.name.function.ts + 2: punctuation.section.parameters.begin.ts + push: function-parameters + - match: '\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?([A-Za-z_$][A-Za-z0-9_$]*)\s*(=>)' + captures: + 1: entity.name.function.ts + 2: variable.parameter.ts + 3: keyword.operator.arrow.ts + + function-parameters: + - meta_scope: meta.parameters.ts + - include: comments + - include: strings + - match: '(\.\.\.)?([A-Za-z_$][A-Za-z0-9_$]*)(\??)(?=\s*(?::|,|\)|=))' + captures: + 1: keyword.operator.spread.ts + 2: variable.parameter.ts + 3: keyword.operator.optional.ts + - match: '\b([A-Za-z_$][A-Za-z0-9_$]*)\b(?=\s*(?:,|}|=))' + captures: + 1: variable.parameter.ts + - match: ':' + scope: punctuation.separator.type.ts + - match: ',' + scope: punctuation.separator.comma.ts + - match: '\)' + scope: punctuation.section.parameters.end.ts + pop: true + + keywords: + - match: '\b(?:import|export|from|as|const|let|var|function|class|interface|type|extends|implements|new|return|if|else|for|while|switch|case|default|try|catch|finally|throw|await|async)\b' + scope: keyword.control.ts + + types: + - match: '\b(?:string|number|boolean|void|unknown|never|any)\b' + scope: storage.type.ts + + literals: + - match: '\b(?:true|false|null|undefined)\b' + scope: constant.language.ts + + function-calls: + - match: '\b(?!if\b|for\b|while\b|switch\b|catch\b|function\b|return\b|typeof\b|new\b|delete\b|void\b|await\b|import\b|export\b|class\b)([A-Za-z_$][A-Za-z0-9_$]*)\s*(?=\()' + captures: + 1: variable.function.ts diff --git a/src/app.rs b/src/app.rs index 72feec0..1c7bfe7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -671,7 +671,7 @@ pub(crate) fn handle_mouse( #[cfg(test)] mod tests { - use super::{build_search_match_line_indexes, next_match_index, AppState}; + use super::{AppState, build_search_match_line_indexes, next_match_index}; use crate::model::{DiffFileDescriptor, DiffFileView, FileContentSource, PaneOffsets}; use std::collections::HashSet; diff --git a/src/diff.rs b/src/diff.rs index a088ae4..5fe18b1 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -15,12 +15,14 @@ use crate::{ DiffFileDescriptor, DiffFileView, FileContentSource, FileLineHighlights, ResolvedComparison, }, review::compute_review_key, + syntax::syntax_set, text::get_max_normalized_line_length, }; const MISSING_LEFT: &str = ""; const MISSING_RIGHT: &str = ""; const BINARY_PLACEHOLDER: &str = ""; +const DOTENV_SYNTAX_NAME: &str = "Dotenv (deff)"; static HUNK_HEADER_RE: Lazy = Lazy::new(|| { Regex::new(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") @@ -374,51 +376,71 @@ fn read_lines_at_working_tree(repo_root: &Path, file_path: &str) -> Vec } } -fn extension_to_language(extension: &str) -> Option<&'static str> { - match extension { - "c" => Some("c"), - "cc" => Some("cpp"), - "cjs" => Some("javascript"), - "cpp" => Some("cpp"), - "css" => Some("css"), - "go" => Some("go"), - "h" => Some("c"), - "hpp" => Some("cpp"), - "htm" => Some("html"), - "html" => Some("html"), - "java" => Some("java"), - "js" => Some("javascript"), - "json" => Some("json"), - "jsx" => Some("jsx"), - "md" => Some("markdown"), - "mjs" => Some("javascript"), - "py" => Some("python"), - "rb" => Some("ruby"), - "rs" => Some("rust"), - "scss" => Some("scss"), - "sh" => Some("bash"), - "sql" => Some("sql"), - "ts" => Some("typescript"), - "tsx" => Some("tsx"), - "xml" => Some("xml"), - "yaml" => Some("yaml"), - "yml" => Some("yaml"), - "zsh" => Some("bash"), - _ => None, - } +fn is_dotenv_file_name(file_name_lower: &str) -> bool { + file_name_lower == ".env" || file_name_lower.starts_with(".env.") } -fn get_language_for_path(file_path: Option<&str>) -> Option { - let file_path = file_path?; +fn detect_syntax_name(file_path: Option<&str>, lines: &[String]) -> Option { + let syntaxes = syntax_set(); + + if let Some(file_path) = file_path { + let path = PathBuf::from(file_path); + + if let Some(file_name) = path.file_name().and_then(|name| name.to_str()) { + let file_name_lower = file_name.to_ascii_lowercase(); + + if is_dotenv_file_name(&file_name_lower) { + if let Some(syntax) = syntaxes + .find_syntax_by_name(DOTENV_SYNTAX_NAME) + .or_else(|| syntaxes.find_syntax_by_token("dotenv")) + .or_else(|| syntaxes.find_syntax_by_extension("env")) + { + return Some(syntax.name.clone()); + } + } + + if let Some(syntax) = syntaxes.find_syntax_by_token(file_name) { + return Some(syntax.name.clone()); + } + + if let Some(syntax) = syntaxes.find_syntax_by_token(&file_name_lower) { + return Some(syntax.name.clone()); + } + + if path.extension().is_none() { + if let Some(syntax) = syntaxes.find_syntax_by_extension(file_name) { + return Some(syntax.name.clone()); + } + + if let Some(syntax) = syntaxes.find_syntax_by_extension(&file_name_lower) { + return Some(syntax.name.clone()); + } + } + } + + if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) { + if let Some(syntax) = syntaxes.find_syntax_by_extension(extension) { + return Some(syntax.name.clone()); + } - let path = PathBuf::from(file_path); - let file_name = path.file_name()?.to_string_lossy().to_lowercase(); - if file_name == "dockerfile" { - return Some("dockerfile".to_string()); + let extension_lower = extension.to_ascii_lowercase(); + if let Some(syntax) = syntaxes.find_syntax_by_extension(&extension_lower) { + return Some(syntax.name.clone()); + } + } } - let extension = path.extension()?.to_string_lossy().to_lowercase(); - extension_to_language(&extension).map(ToOwned::to_owned) + let first_line = lines + .iter() + .find(|line| !line.trim().is_empty()) + .or_else(|| lines.first()); + let Some(first_line) = first_line else { + return None; + }; + + syntaxes + .find_syntax_by_first_line(first_line) + .map(|syntax| syntax.name.clone()) } pub(crate) fn build_file_views( @@ -468,8 +490,8 @@ pub(crate) fn build_file_views( views.push(DiffFileView { descriptor: descriptor.clone(), review_key: compute_review_key(descriptor, &left_lines, &right_lines), - left_language: get_language_for_path(descriptor.base_path.as_deref()), - right_language: get_language_for_path(descriptor.head_path.as_deref()), + left_language: detect_syntax_name(descriptor.base_path.as_deref(), &left_lines), + right_language: detect_syntax_name(descriptor.head_path.as_deref(), &right_lines), left_deleted_line_indexes: line_highlights.left_deleted_line_indexes, right_added_line_indexes: line_highlights.right_added_line_indexes, left_max_content_length: get_max_normalized_line_length(&left_lines), @@ -487,7 +509,8 @@ mod tests { use crate::model::FileContentSource; use super::{ - parse_diff_name_status_output, parse_line_highlights_from_patch, split_into_lines, + detect_syntax_name, parse_diff_name_status_output, parse_line_highlights_from_patch, + split_into_lines, }; #[test] @@ -519,4 +542,88 @@ mod tests { let lines = split_into_lines("a\nb\n"); assert_eq!(lines, vec!["a".to_string(), "b".to_string()]); } + + #[test] + fn detect_syntax_uses_filename_token_when_no_extension() { + let lines = vec!["echo hello".to_string()]; + let detected = detect_syntax_name(Some("bash"), &lines); + assert_eq!(detected.as_deref(), Some("Bourne Again Shell (bash)")); + } + + #[test] + fn detect_syntax_uses_extension_when_available() { + let lines = vec!["fn main() {}".to_string()]; + let detected = detect_syntax_name(Some("src/main.rs"), &lines); + assert_eq!(detected.as_deref(), Some("Rust")); + } + + #[test] + fn detect_syntax_uses_shebang_for_extensionless_files() { + let lines = vec!["#!/usr/bin/env bash".to_string(), "echo hello".to_string()]; + let detected = detect_syntax_name(Some("scripts/release"), &lines); + assert_eq!(detected.as_deref(), Some("Bourne Again Shell (bash)")); + } + + #[test] + fn detect_syntax_uses_bundled_tsx_grammar() { + let lines = vec!["export const App = () =>
Hello
;".to_string()]; + let detected = detect_syntax_name(Some("src/App.tsx"), &lines); + assert_eq!(detected.as_deref(), Some("TSX (deff)")); + } + + #[test] + fn detect_syntax_uses_bundled_jsx_grammar() { + let lines = vec!["export default () =>
Hello
;".to_string()]; + let detected = detect_syntax_name(Some("src/App.jsx"), &lines); + assert_eq!(detected.as_deref(), Some("JSX (deff)")); + } + + #[test] + fn detect_syntax_uses_bundled_typescript_grammar() { + let lines = vec!["const answer: number = 42;".to_string()]; + let detected = detect_syntax_name(Some("src/types.ts"), &lines); + assert_eq!(detected.as_deref(), Some("TypeScript (deff)")); + } + + #[test] + fn detect_syntax_uses_bundled_handlebars_grammar() { + let lines = vec!["

{{title}}

".to_string()]; + let detected = detect_syntax_name(Some("templates/view.hbs"), &lines); + assert_eq!(detected.as_deref(), Some("Handlebars (deff)")); + } + + #[test] + fn detect_syntax_uses_bundled_dotenv_grammar_for_dotenv_file() { + let lines = vec!["API_KEY=secret".to_string()]; + let detected = detect_syntax_name(Some(".env"), &lines); + assert_eq!(detected.as_deref(), Some("Dotenv (deff)")); + } + + #[test] + fn detect_syntax_uses_bundled_dotenv_grammar_for_dotenv_variant_file() { + let lines = vec!["NEXT_PUBLIC_URL=https://example.com".to_string()]; + let detected = detect_syntax_name(Some("config/.env.example"), &lines); + assert_eq!(detected.as_deref(), Some("Dotenv (deff)")); + } + + #[test] + fn detect_syntax_uses_bundled_dotenv_grammar_for_any_dotenv_suffix() { + let lines = vec!["NEXT_PUBLIC_URL=https://example.com".to_string()]; + let detected = detect_syntax_name(Some("config/.env.production.local"), &lines); + assert_eq!(detected.as_deref(), Some("Dotenv (deff)")); + } + + #[test] + fn detect_syntax_uses_bundled_kotlin_grammar() { + let lines = vec!["fun main() = println(\"Hello\")".to_string()]; + let detected = detect_syntax_name(Some("src/main.kt"), &lines); + assert_eq!(detected.as_deref(), Some("Kotlin (deff)")); + } + + #[test] + fn detect_syntax_returns_none_for_unknown_content() { + let lines = vec!["this should not match a known first-line rule".to_string()]; + let detected = detect_syntax_name(Some("notes.customext"), &lines); + assert_eq!(detected, None); + } } diff --git a/src/lib.rs b/src/lib.rs index b15737b..b92232a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ mod git; mod model; mod render; mod review; +mod syntax; mod terminal; mod text; diff --git a/src/render.rs b/src/render.rs index 8552eab..8d98f8d 100644 --- a/src/render.rs +++ b/src/render.rs @@ -8,13 +8,14 @@ use ratatui::{ use syntect::{ easy::HighlightLines, highlighting::{FontStyle, Theme, ThemeSet}, - parsing::{SyntaxReference, SyntaxSet}, + parsing::SyntaxReference, }; use crate::{ model::{ DiffFileView, LineHighlightKind, PaneOffsets, PaneSide, ResolvedComparison, ThemeMode, }, + syntax::syntax_set, text::{fit_line, normalize_content, normalized_char_count, pad_to_width, slice_chars}, }; @@ -37,7 +38,6 @@ const DARK_THEME_CANDIDATES: &[&str] = &[ const LIGHT_THEME_CANDIDATES: &[&str] = &["InspiredGitHub", "Solarized (light)", "base16-ocean.light"]; -static SYNTAX_SET: Lazy = Lazy::new(SyntaxSet::load_defaults_newlines); static THEME_SET: Lazy = Lazy::new(ThemeSet::load_defaults); static THEME_MODE_OVERRIDE: OnceCell = OnceCell::new(); static THEME: Lazy = Lazy::new(|| { @@ -131,9 +131,12 @@ fn should_prefer_dark_theme() -> bool { } fn syntax_for_language(language: &str) -> Option<&'static SyntaxReference> { - SYNTAX_SET - .find_syntax_by_token(language) - .or_else(|| SYNTAX_SET.find_syntax_by_extension(language)) + let syntaxes = syntax_set(); + + syntaxes + .find_syntax_by_name(language) + .or_else(|| syntaxes.find_syntax_by_token(language)) + .or_else(|| syntaxes.find_syntax_by_extension(language)) } fn base_style(tint_background: Option) -> Style { @@ -190,8 +193,9 @@ fn highlight_visible_content( return default_span(); }; + let syntaxes = syntax_set(); let mut highlighter = HighlightLines::new(syntax, &THEME); - let highlighted = match highlighter.highlight_line(value, &SYNTAX_SET) { + let highlighted = match highlighter.highlight_line(value, syntaxes) { Ok(ranges) => ranges, Err(_) => return default_span(), }; diff --git a/src/syntax.rs b/src/syntax.rs new file mode 100644 index 0000000..cf6e4e6 --- /dev/null +++ b/src/syntax.rs @@ -0,0 +1,70 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, +}; + +use once_cell::sync::Lazy; +use syntect::parsing::SyntaxSet; + +const DEFAULT_RELATIVE_SYNTAX_DIRS: &[&str] = &["assets/syntaxes", ".deff/syntaxes"]; +const ENV_SYNTAX_DIR: &str = "DEFF_SYNTAX_DIR"; +const ENV_SYNTAX_PATHS: &str = "DEFF_SYNTAX_PATHS"; + +static SYNTAX_SET: Lazy = Lazy::new(load_syntax_set); + +pub(crate) fn syntax_set() -> &'static SyntaxSet { + &SYNTAX_SET +} + +fn load_syntax_set() -> SyntaxSet { + let mut builder = SyntaxSet::load_defaults_newlines().into_builder(); + + for directory in syntax_directories() { + if let Err(error) = builder.add_from_folder(&directory, true) { + eprintln!( + "deff: ignoring syntax directory {}: {error}", + directory.display() + ); + } + } + + builder.build() +} + +fn syntax_directories() -> Vec { + let mut candidates = Vec::new(); + candidates.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/syntaxes")); + candidates.extend(DEFAULT_RELATIVE_SYNTAX_DIRS.iter().map(PathBuf::from)); + + if let Some(value) = std::env::var_os(ENV_SYNTAX_PATHS) { + candidates.extend(std::env::split_paths(&value)); + } + + if let Some(value) = std::env::var_os(ENV_SYNTAX_DIR) { + candidates.push(PathBuf::from(value)); + } + + let cwd = std::env::current_dir().ok(); + let mut unique = HashSet::new(); + let mut resolved = Vec::new(); + for candidate in candidates { + let absolute = if candidate.is_relative() { + match cwd.as_ref() { + Some(directory) => directory.join(candidate), + None => candidate, + } + } else { + candidate + }; + + if !absolute.is_dir() { + continue; + } + + if unique.insert(absolute.clone()) { + resolved.push(absolute); + } + } + + resolved +}