diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index f54acfe..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,5 +0,0 @@ -blank_issues_enabled: true -contact_links: - - name: 🔒 Report a security vulnerability - url: https://github.com/aaronbcarlisle/python-syntax-enhanced/security/advisories/new - about: Please report security issues privately — never in a public issue. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e540fc1..49f8f97 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,32 +1,11 @@ ## Summary - + -## Changes +## Checklist - - ---- - -## Required Checks - -### Tests -- [ ] This PR does **not** change `syntax/`, `plugin/` or `ftplugin/` (skip below) -- [ ] Added or updated assertions in `tests/test_syntax.vim` (or `tests/test_loading.vim`) that fail without the change -- [ ] `sh tests/run.sh` passes locally - -### Docs -- [ ] This PR does **not** change user-visible behavior or options (skip below) -- [ ] README and `doc/python-syntax-enhanced.txt` updated (options, commands, color table) -- [ ] Changelog entry added in `doc/python-syntax-enhanced.txt` - -### Fork PR Guard (if workflows changed) -- [ ] This PR does **not** modify `.github/workflows/` (skip below) -- [ ] Any PR-write action (labels, comments, assignees) has a fork guard: - ```yaml - if: github.event.pull_request.head.repo.full_name == github.repository - ``` - -### General -- [ ] `git diff --check` passes (no whitespace errors) -- [ ] CI passes on Vim (Linux, macOS, Windows) and Neovim +- [ ] Syntax or plugin changes have assertions in `tests/test_syntax.vim` or + `tests/test_loading.vim` that fail without the change +- [ ] `sh tests/run.sh` passes +- [ ] User-visible changes are in the README, `doc/python-syntax-enhanced.txt` + and its changelog diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml deleted file mode 100644 index 0458c1a..0000000 --- a/.github/workflows/pr-automation.yml +++ /dev/null @@ -1,168 +0,0 @@ -name: PR Automation - -on: - pull_request: - # synchronize: re-label when new commits change which paths are touched - types: [opened, edited, reopened, synchronize] - -jobs: - auto-assign-and-label: - name: Auto-assign and Label PR - # Fork PRs and Dependabot PRs get a read-only token, so these writes would - # fail (Dependabot applies its own "dependencies" labels anyway) - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - github.actor != 'dependabot[bot]' - runs-on: ubuntu-latest - permissions: - pull-requests: write - issues: write - contents: read - - steps: - - name: Auto-assign PR creator - uses: actions/github-script@v9 - with: - script: | - const pr = context.payload.pull_request; - if (pr.assignees.length > 0) { - console.log('PR already has assignees; leaving them as-is'); - return; - } - try { - await github.rest.issues.addAssignees({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - assignees: [pr.user.login], - }); - console.log(`Assigned PR to ${pr.user.login}`); - } catch (error) { - // Bots (e.g. dependabot) cannot be assigned; keep going to labels - console.error(`Failed to auto-assign PR creator: ${error.message}`); - } - - - name: Label from PR title and changed files - uses: actions/github-script@v9 - with: - script: | - const pr = context.payload.pull_request; - const labels = new Set(); - - // Conventional commit prefix, with optional scope and "!": - // "fix: ...", "feat(syntax): ...", "refactor!: ..." - const prefixLabels = { - fix: 'bug', - feat: 'enhancement', - docs: 'documentation', - chore: 'chore', - refactor: 'refactor', - test: 'testing', - style: 'style', - perf: 'performance', - ci: 'ci/cd', - build: 'build', - }; - const prefix = pr.title.toLowerCase().match(/^(\w+)(\([^)]*\))?!?:/); - if (prefix && prefixLabels[prefix[1]]) { - labels.add(prefixLabels[prefix[1]]); - } - - // Paths touched by the PR - const files = await github.paginate(github.rest.pulls.listFiles, { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - per_page: 100, - }); - const paths = files.map(f => f.filename); - const touches = test => paths.some(test); - - if (touches(p => p.startsWith('syntax/'))) labels.add('syntax'); - if (touches(p => /^(plugin|ftplugin|autoload)\//.test(p))) labels.add('plugin'); - if (touches(p => p.startsWith('tests/'))) labels.add('testing'); - if (touches(p => p.startsWith('.github/'))) labels.add('ci/cd'); - if (touches(p => p.startsWith('doc/') || p.endsWith('.md'))) labels.add('documentation'); - - // Every label this workflow can apply is owned by it: each run - // reconciles them to the current title and paths (so a label from - // an earlier push or title is removed), while labels outside this - // set (e.g. "wontfix", "good first issue") are never touched. - const colors = { - 'bug': 'd73a4a', - 'enhancement': 'a2eeef', - 'documentation': '0075ca', - 'chore': 'fef2c0', - 'refactor': 'c5def5', - 'testing': '5319e7', - 'style': 'fbca04', - 'performance': '7057ff', - 'ci/cd': 'e99695', - 'build': 'bfdadc', - 'syntax': '1d76db', - 'plugin': 'd4c5f9', - }; - - const { data: current } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - per_page: 100, - }); - const currentNames = new Set(current.map(l => l.name)); - - const stale = [...currentNames].filter(n => n in colors && !labels.has(n)); - for (const name of stale) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - name, - }); - console.log(`Removed stale label: ${name}`); - } catch (e) { - console.error(`Failed to remove label '${name}': ${e.message}`); - } - } - - const missing = [...labels].filter(n => !currentNames.has(n)); - if (missing.length === 0) { - console.log('Labels already up to date'); - return; - } - - // Create any label that doesn't exist in the repo yet - for (const name of missing) { - try { - await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name, - }); - } catch (e) { - if (e.status !== 404) { - console.error(`Error checking label '${name}': ${e.message}`); - continue; - } - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name, - color: colors[name] || 'ededed', - }); - console.log(`Created label: ${name}`); - } catch (createError) { - console.error(`Failed to create label '${name}': ${createError.message}`); - } - } - } - - console.log(`Adding labels: ${missing.join(', ')}`); - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: missing, - }); diff --git a/AUTHORS b/AUTHORS index efa510b..d2feb5d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,32 +1,15 @@ Python Syntax Enhanced Authors ============================== -This file lists authors for copyright attribution purposes. - Maintainer: Aaron Carlisle -Acknowledgments: - -This plugin was developed with inspiration and reference from: - - Vim's built-in Python syntax file: - - Zvezdan Petkovic - - Neil Schemenauer - - Dmitry Vasiliev +syntax/python.vim is based on Vim's built-in Python syntax file: - vim-python/python-syntax project: - - Dmitry Vasiliev - - python-syntax contributors - - https://github.com/vim-python/python-syntax + Zvezdan Petkovic (maintainer) + Neil Schemenauer + Dmitry Vasiliev - Python Enhancement Proposals (PEPs) for typing: - - PEP 484 - Type Hints - - PEP 526 - Syntax for Variable Annotations - - PEP 544 - Protocols: Structural subtyping - - PEP 585 - Type Hinting Generics In Standard Collections - - PEP 604 - Allow writing union types as X | Y - - PEP 612 - Parameter Specification Variables - - PEP 673 - Self Type - - PEP 695 - Type Parameter Syntax +Option and group names follow vim-python/python-syntax +(https://github.com/vim-python/python-syntax) for familiarity. diff --git a/README.md b/README.md index c33d270..60c3836 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,53 @@ [![Tests](https://github.com/aaronbcarlisle/python-syntax-enhanced/actions/workflows/test.yml/badge.svg)](https://github.com/aaronbcarlisle/python-syntax-enhanced/actions/workflows/test.yml) -**IDE-level Python syntax highlighting for Vim with comprehensive type annotation support.** +Python syntax highlighting for Vim that understands type annotations. -Vim's built-in Python syntax and existing plugins like `vim-python/python-syntax` don't properly handle modern Python type hints. The `->` return type arrow breaks highlighting, generic types aren't recognized, and typing module constructs are ignored. This plugin fixes all of that. +Vim's built-in Python syntax has no notion of annotations: `->`, `list[int]` +and `Optional[User]` are colored like any other code, and `str` looks the same +in `name: str` as in `str(x)`. This plugin replaces `syntax/python.vim` with +one that colors types only where they are types: parameter and return +annotations, `x: T` and `self.attr: T`, class bases, and PEP 695 type +parameters and `type` aliases. ![enhanced-syntax-highlighting-demo](https://github.com/user-attachments/assets/4295c9fb-e658-472e-978c-923091fbc221) ## Features -- **Return type arrows** - `->` is properly highlighted (no more broken syntax!) -- **Type expressions only in annotations** - Parameters, returns, assignments, class bases, PEP 695 type params -- **Generic types** - `list[int]`, `dict[str, Any]`, `tuple[int, ...]` -- **Union types** - `X | Y` (Python 3.10+), only inside type regions (bitwise `|` stays an operator) -- **typing module** - Optional, Callable, Annotated, TypeIs, ReadOnly, NotRequired, Protocol, etc. -- **User / CapWords types** - `User`, `Comparable`, and other aliases color inside annotations -- **Type comments** - `# type: int` (PEP 484 legacy style) -- **Python 3.12+ type syntax** - `def func[T](x: T) -> T:`, `type Point = tuple[int, int]` -- **Enhanced f-strings** - Including debug specifier `f"{x=}"` -- **match/case** and **except\*** - Python 3.10+ / 3.11+ -- **Green docstrings** - Via nextgroup after the header colon (`skipnl`/`skipempty`, including wrapped signatures and blank lines) -- **Comments in annotations** - Trailing `# ...` notes stay comments, not fake type names -- **Distinct color scheme** - Different colors for types, primitives, classes (optional) - -## Color Scheme - -| Element | Color | Highlight group | -|---------|-------|-----------------| +- Return arrows, generics (`dict[str, list[int]]`), unions (`X | Y`) and + forward references (`"User"`) inside annotations +- `list(...)` in regular code stays a builtin, and `key: value` lines in a + multi-line dict or call are not mistaken for annotations +- `|` is a union only inside a type; elsewhere it is the bitwise operator +- `typing` names (`Optional`, `Callable`, `Annotated`, `Protocol`, `TypeIs`, + ...) and user types (`User`, `T`) inside annotations +- Python 3.12 type parameters and aliases: `def f[T](x: T) -> T:`, + `type Point = tuple[int, int]` +- `# type: int` comments (`# type: ignore` stays a plain comment) +- Docstrings, including after wrapped signatures and comment lines +- f-strings (including `f"{x=}"`), `match`/`case`, `except*` +- An optional color palette for the new groups (see below) + +## Colors + +By default every group is linked to a standard group (`Type`, `Operator`, +`String`, ...), so your colorscheme decides the colors. The screenshot above +uses the plugin's own palette, which you can turn on with: + +```vim +let g:python_enhanced_colors = 1 +``` + +| Element | Palette color | Highlight group | +|---------|---------------|-----------------| | Class names | Cyan | `pythonClass` | | `Optional`, `Callable`, `Generic`, etc. | Orange | `pythonTypingType` | -| `str`, `int`, `bool`, `list`, `dict` | Blue | `pythonPrimitiveType` | -| CapWords / aliases in annotations | Type link | `pythonTypeName` | +| `str`, `int`, `bool`, `list`, `dict` in annotations | Blue | `pythonPrimitiveType` | | `->` and `\|` (union) | Magenta | `pythonReturnArrow`, `pythonTypeUnion` | -| `self`, `cls` | Orange | `pythonSelfRef` | | Builtins in code (`print`, `len`, `str(...)`) | Lavender | `pythonBuiltin` | | Docstrings | Green | `pythonDocstring` | +| User types in annotations (`User`, `T`) | colorscheme `Type` | `pythonTypeName` | +| `self`, `cls` | colorscheme `Identifier` | `pythonClassVar` | ## Installation @@ -69,12 +82,13 @@ with Vim 9.1. It replaces Vim's built-in `syntax/python.vim`, so disable other Python syntax plugins (see [Troubleshooting](#troubleshooting)). In Neovim this is a regular syntax file: it applies when Python is highlighted -by the syntax engine (Neovim's default), not when Tree-sitter highlighting is -enabled for Python (e.g. via nvim-treesitter's `highlight` module). +by the syntax engine (Neovim's default). If Tree-sitter highlighting is enabled +for Python, the plugin does not load. ## Configuration -Add to your vimrc **BEFORE** `syntax on`: +Options are read when a Python buffer's syntax is loaded, so set them in your +vimrc (after changing one, reopen the file with `:e`). ```vim " Enable all features @@ -93,7 +107,7 @@ let g:python_highlight_exceptions = 1 " Exceptions (default: 1) let g:python_highlight_string_formatting = 1 " String formatting (default: 1) let g:python_highlight_doctests = 1 " Doctests (default: 1) let g:python_highlight_space_errors = 0 " Space errors (default: 0) -let g:python_enhanced_colors = 1 " Built-in palette (default: 1; 0 = hi def link only) +let g:python_enhanced_colors = 1 " Built-in palette (default: 0) ``` For large files: @@ -101,74 +115,26 @@ For large files: let g:python_slow_sync = 1 " syntax sync fromstart ``` -## Type Annotations Supported +## What counts as a type -### Return Types -```python -def greet(name: str) -> str: - return f"Hello, {name}" -``` +Type colors apply inside these positions only; everywhere else the same names +keep their normal highlighting: -### Parameter Annotations ```python -def process(data: bytes, count: int = 10) -> None: - pass -``` +def find[T](items: list[T], key: Callable[[T], str] | None = None) -> T | None: ... -### Variable Annotations -```python -users: list[User] = [] -config: Final[dict[str, Any]] = {} -self._items: list[T] = [] # dotted attribute targets -``` - -### Generic Types -```python -from typing import List, Dict, Optional, Callable +class Stack(Generic[T], Protocol): ... -def get_users() -> List[Dict[str, Any]]: ... -def find(id: int) -> Optional[User]: ... -def apply(func: Callable[[int], str]) -> None: ... -``` - -### Union Types (Python 3.10+) -```python -def parse(value: str | int | None) -> dict: - pass -``` - -### TypeVar & Generic Classes -```python -from typing import TypeVar, Generic - -T = TypeVar('T') - -class Stack(Generic[T]): - def push(self, item: T) -> None: ... - def pop(self) -> T: ... -``` - -### Protocol Classes -```python -from typing import Protocol, Self - -class Comparable(Protocol): - def __lt__(self, other: Self) -> bool: ... -``` - -### Type Aliases (Python 3.12+) -```python -type Point = tuple[int, int] -type Vector[T] = list[T] +users: dict[str, "User"] = {} +self._items: list[T] = [] +type Pair[K] = tuple[K, K] +x = [] # type: list[int] ``` ## Customizing Colors -By default the plugin applies a small palette on syntax load and on `ColorScheme`. -Set `let g:python_enhanced_colors = 0` to keep only `hi def link` defaults so your -colorscheme owns the groups. - -Override colors in your vimrc (after loading the plugin / colorscheme): +To change single colors, override the groups in your vimrc after your +colorscheme (with the palette on, a later `:colorscheme` re-applies it): ```vim " Example: Make typing types cyan instead of orange @@ -198,8 +164,18 @@ nnoremap ss :syntax sync fromstart ``` **Old syntax file interfering:** -This plugin force-loads its own `syntax/python.vim` on `FileType python`. Disable other -Python syntax plugins (like `vim-python/python-syntax`) to avoid conflicts. +Disable other Python syntax plugins (like `vim-python/python-syntax`). If one +of them wins the runtimepath lookup, this plugin clears it and loads its own +`syntax/python.vim` on `FileType python`, so the other one is loaded for nothing. + +## Limitations + +- Types are recognized by position, not by analysis: any name inside an + annotation is colored as a type, and `typing` names are matched by name. +- Statement annotations (`x: int = 1`) are recognized at the start of a line + only, not after `;`. +- The file replaces Vim's `syntax/python.vim`, whose `python_no_*_highlight` + options are not read (`python_highlight_all` is). ## Testing @@ -224,4 +200,4 @@ See [AUTHORS](AUTHORS) for full attribution. - Vim's built-in Python syntax (Zvezdan Petkovic) - vim-python/python-syntax (for inspiration) -- Python typing PEPs (484, 526, 544, 585, 604, 612, 673, 695) +- Python typing PEPs (484, 526, 544, 585, 604, 612, 673, 695, 742) diff --git a/doc/python-syntax-enhanced.txt b/doc/python-syntax-enhanced.txt index 5c25a9e..7a097a1 100644 --- a/doc/python-syntax-enhanced.txt +++ b/doc/python-syntax-enhanced.txt @@ -1,9 +1,9 @@ -*python-syntax-enhanced.txt* IDE-level Python syntax highlighting +*python-syntax-enhanced.txt* Python syntax with type annotation highlighting PYTHON SYNTAX ENHANCED - ===================== + ====================== -Author: Aaron Carlisle / ABC-Terminal +Author: Aaron Carlisle License: MIT Version: 1.1.0 @@ -17,17 +17,20 @@ CONTENTS *python-syntax-enhanced-contents* 5. Highlight Groups ............ |python-syntax-enhanced-groups| 6. Commands .................... |python-syntax-enhanced-commands| 7. Troubleshooting ............. |python-syntax-enhanced-trouble| - 8. Changelog ................... |python-syntax-enhanced-changelog| + 8. Limitations ................. |python-syntax-enhanced-limits| + 9. Changelog ................... |python-syntax-enhanced-changelog| ============================================================================== 1. INTRODUCTION *python-syntax-enhanced-intro* -Python Syntax Enhanced provides IDE-level syntax highlighting for Python, -with particular focus on type annotations that are missing or broken in -Vim's built-in syntax and other Python syntax plugins. +Python Syntax Enhanced replaces Vim's syntax/python.vim with one that +highlights type annotations, which Vim's built-in Python syntax does not +distinguish from other code. -On |FileType| python the plugin clears syntax and sources its own -syntax/python.vim by absolute path so stock runtime files cannot shadow it. +If another python syntax file won the 'runtimepath' lookup, the plugin loads +its own syntax/python.vim on |FileType| python instead. It does nothing when +'syntax' is not "python" (|:syntax-off|, |:syn-manual|, or Neovim +Tree-sitter highlighting). Features:~ - Type expressions only inside real annotation contexts @@ -40,11 +43,11 @@ Features:~ - CapWords / user aliases inside annotations (`User`, `Comparable`) - Type comments: `# type: int` - Python 3.12+ type parameters and `type` aliases - - Docstrings via nextgroup after the header colon (skipnl/skipempty) + - Docstrings, including after wrapped signatures and comment lines - Trailing comments inside annotations stay comments, not type names - - Enhanced f-string support with debug specifier + - f-strings, including the debug specifier `f"{x=}"` - match/case and except* (Python 3.10+ / 3.11+) - - Operator highlighting (longest match first; `->` is never an operator) + - Operator highlighting (`->` is never an operator) ============================================================================== 2. INSTALLATION *python-syntax-enhanced-install* @@ -53,23 +56,22 @@ Using vim-plug:~ > Plug 'aaronbcarlisle/python-syntax-enhanced' < -Or for local installation:~ -> - Plug '~/.vim/plugged/python-syntax-enhanced' -< Using Vundle:~ > Plugin 'aaronbcarlisle/python-syntax-enhanced' < -Manual Installation:~ - -Copy the plugin directory to `~/.vim/plugged/python-syntax-enhanced/` or -`~/.vim/pack/plugins/start/python-syntax-enhanced/`. +Using Vim packages (|packages|):~ +> + git clone https://github.com/aaronbcarlisle/python-syntax-enhanced.git \ + ~/.vim/pack/plugins/start/python-syntax-enhanced +< +On Windows use `~/vimfiles/pack/plugins/start/`. Then run `:helptags ALL`. ============================================================================== 3. CONFIGURATION *python-syntax-enhanced-config* -Add these to your vimrc BEFORE `syntax on`: +Options are read when a Python buffer's syntax is loaded; set them in your +vimrc (after changing one, reopen the file with |:edit|). Enable all features at once:~ > @@ -86,17 +88,17 @@ Or configure individually:~ let g:python_highlight_string_formatting = 1 " String formatting (default: 1) let g:python_highlight_doctests = 1 " Doctests (default: 1) let g:python_highlight_space_errors = 0 " Space errors (default: 0) - let g:python_enhanced_colors = 1 " Palette on load/ColorScheme + let g:python_enhanced_colors = 0 " Built-in palette (default: 0) < -Set `g:python_enhanced_colors` to 0 to use only |:hi-default| links so a -colorscheme owns every group. +By default every group is linked to a standard group (|:hi-default|), so your +colorscheme decides the colors. Set `g:python_enhanced_colors` to 1 for the +plugin's own palette, applied on load and again after every |:colorscheme|. For large files with complex nesting:~ > let g:python_slow_sync = 1 " syn sync fromstart < Otherwise the syntax file syncs at def/class lines with minlines=100. -The ftplugin does not force 'foldmethod'. ============================================================================== 4. TYPE ANNOTATIONS *python-syntax-enhanced-types* @@ -110,7 +112,7 @@ Type highlighting applies only inside annotation regions: - Class bases `class Foo(Bar, Generic[T])` - PEP 695 type parameters and `type` aliases -Outside those regions, `list(` stays |pythonBuiltin|. +Outside those regions, `list(` stays `pythonBuiltin`. Return Type Annotations:~ > @@ -154,7 +156,7 @@ TypeVar and Generics:~ < Protocol Classes:~ > - from typing import Protocol + from typing import Protocol, Self class Comparable(Protocol): def __lt__(self, other: Self) -> bool: ... @@ -180,7 +182,7 @@ Type Annotation Groups:~ pythonReturnType Return type region pythonStmtAnnotation Statement annotation region pythonDocstring Module / function / class docstrings - pythonSelfRef self / cls / mcs + pythonClassVar self / cls / mcs Core Groups:~ @@ -198,7 +200,7 @@ Core Groups:~ Customizing Colors:~ -Add to your vimrc after colorscheme (or set g:python_enhanced_colors = 0): +Add to your vimrc after your colorscheme: > hi pythonTypingType ctermfg=Cyan guifg=#56B6C2 hi pythonPrimitiveType ctermfg=Yellow guifg=#E5C07B @@ -224,19 +226,31 @@ Syntax breaks in long files:~ Or use `:syntax sync fromstart` Type annotations not highlighted:~ - Ensure `let g:python_highlight_type_annotations = 1` is set BEFORE - `syntax on` in your vimrc. + Check `:PythonSyntaxInfo`, and that `g:python_highlight_type_annotations` + is not 0. In Neovim, Tree-sitter highlighting for Python replaces this + plugin. Conflicts with other plugins:~ - This plugin force-loads its syntax file on FileType python. Disable any - other Python syntax plugins (like vim-python/python-syntax). + Disable any other Python syntax plugins (like vim-python/python-syntax). + If one wins the runtimepath lookup, this plugin clears it and loads its + own file on FileType python, so the other one is loaded for nothing. Manual syntax resync:~ If highlighting breaks mid-editing, use `:syntax sync fromstart` or create a mapping: `nnoremap ss :syntax sync fromstart` ============================================================================== -8. CHANGELOG *python-syntax-enhanced-changelog* +8. LIMITATIONS *python-syntax-enhanced-limits* + + - Types are recognized by position, not by analysis: any name inside an + annotation is colored as a type, and typing names are matched by name. + - Statement annotations (`x: int = 1`) are recognized at the start of a + line only, not after `;`. + - Vim's built-in `python_no_*_highlight` options are not read + (`python_highlight_all` is). + +============================================================================== +9. CHANGELOG *python-syntax-enhanced-changelog* 1.1.0 (2026-09-26)~ - Load our syntax/python.vim once per buffer, taking over on FileType @@ -252,9 +266,17 @@ Manual syntax resync:~ - Docstrings via nextgroup after header colon (skipnl/skipempty) - Comments allowed inside type regions so trailing # notes are not types - except*, type statement vs type(), Annotated/TypeIs/ReadOnly/NotRequired - - Color palette via one function on load + ColorScheme + - Color palette is opt-in (g:python_enhanced_colors = 1); by default + the colorscheme decides every color - ftplugin no longer forces foldmethod; sync via g:python_slow_sync - :PythonSyntaxEnableAll reloads current buffer only + - Calls, dicts and lambdas in parameter defaults no longer break the + highlighting of later parameters and the return type + - `# type: ignore` stays a comment + - Not loaded under :syntax off / manual or Neovim Tree-sitter + - Removed ftplugin/python.vim, which had no effect + - g:python_enhanced_highlight_all no longer overwrites the individual + g:python_highlight_* variables - Tests (tests/run.sh) run in CI on Vim (Linux/macOS/Windows) and Neovim 1.0.0 (2025-12-18)~ diff --git a/ftplugin/python.vim b/ftplugin/python.vim deleted file mode 100644 index f14a473..0000000 --- a/ftplugin/python.vim +++ /dev/null @@ -1,18 +0,0 @@ -" ftplugin/python.vim - Python filetype settings for enhanced syntax -" -" Folding is left to the user / other ftplugins. Syntax sync is controlled -" by g:python_slow_sync in syntax/python.vim. - -if exists("b:did_ftplugin_python_enhanced") - finish -endif -let b:did_ftplugin_python_enhanced = 1 - -" Allow undoing this ftplugin's buffer-local state -if exists("b:undo_ftplugin") - let b:undo_ftplugin .= " | unlet! b:did_ftplugin_python_enhanced" -else - let b:undo_ftplugin = "unlet! b:did_ftplugin_python_enhanced" -endif - -" vim:set sw=2 sts=2 ts=8 noet: diff --git a/plugin/python-syntax-enhanced.vim b/plugin/python-syntax-enhanced.vim index e50dca8..e08505d 100644 --- a/plugin/python-syntax-enhanced.vim +++ b/plugin/python-syntax-enhanced.vim @@ -1,7 +1,7 @@ " plugin/python-syntax-enhanced.vim - Plugin initialization " -" Python Syntax Enhanced - IDE-level Python syntax highlighting for Vim -" Maintainer: Aaron Carlisle / ABC-Terminal +" Python Syntax Enhanced - Python syntax highlighting with type annotations +" Maintainer: Aaron Carlisle " Version: 1.1.0 if exists('g:loaded_python_syntax_enhanced') @@ -16,11 +16,11 @@ set cpo&vim let s:plugin_root = expand(':p:h:h') " --------------------------------------------------------------------------- -" Color palette (survives :colorscheme; optional opt-out) +" Optional color palette (off by default; survives :colorscheme) " --------------------------------------------------------------------------- function! PythonSyntaxEnhancedApplyColors() abort - if !get(g:, 'python_enhanced_colors', 1) + if !get(g:, 'python_enhanced_colors', 0) return endif @@ -34,8 +34,6 @@ function! PythonSyntaxEnhancedApplyColors() abort hi pythonReturnArrow ctermfg=170 guifg=#d75fd7 " Union operator - same as arrow hi pythonTypeUnion ctermfg=170 guifg=#d75fd7 - " self / cls - orange - hi pythonSelfRef ctermfg=209 guifg=#ff875f " Builtins (print, len, ...) - lavender, distinct from yellow function calls hi pythonBuiltin ctermfg=139 guifg=#b294bb " Docstrings - green @@ -56,8 +54,10 @@ endfunction " Normally Vim's FileType/Syntax machinery has already sourced our file by " the time this runs; only take over when another python syntax won. +" 'syntax' is not "python" under :syntax off / :syntax manual, or after +" Neovim's Tree-sitter highlighter cleared it: leave those buffers alone. function! s:LoadSyntax(force) abort - if !a:force && s:OursLoaded() + if !a:force && (&l:syntax !=# 'python' || s:OursLoaded()) return endif @@ -75,20 +75,27 @@ function! s:EnableAll() abort endif endfunction +" ON/OFF for g:python_highlight_{name}, as syntax/python.vim reads it +function! s:State(name, default) abort + let l:all = a:name !=# 'space_errors' && (get(g:, 'python_enhanced_highlight_all', 0) + \ || get(g:, 'python_highlight_all', 0)) + return l:all || get(g:, 'python_highlight_' . a:name, a:default) ? 'ON' : 'OFF' +endfunction + function! s:ShowInfo() abort echo "Python Syntax Enhanced v1.1.0" echo "" echo "Active features:" - echo " Type annotations: " . (get(g:, 'python_highlight_type_annotations', 1) ? 'ON' : 'OFF') - echo " Operators: " . (get(g:, 'python_highlight_operators', 1) ? 'ON' : 'OFF') - echo " Function calls: " . (get(g:, 'python_highlight_func_calls', 0) ? 'ON' : 'OFF') - echo " Class vars: " . (get(g:, 'python_highlight_class_vars', 1) ? 'ON' : 'OFF') - echo " Builtins: " . (get(g:, 'python_highlight_builtins', 1) ? 'ON' : 'OFF') - echo " Exceptions: " . (get(g:, 'python_highlight_exceptions', 1) ? 'ON' : 'OFF') - echo " String format: " . (get(g:, 'python_highlight_string_formatting', 1) ? 'ON' : 'OFF') - echo " Doctests: " . (get(g:, 'python_highlight_doctests', 1) ? 'ON' : 'OFF') - echo " Space errors: " . (get(g:, 'python_highlight_space_errors', 0) ? 'ON' : 'OFF') - echo " Enhanced colors: " . (get(g:, 'python_enhanced_colors', 1) ? 'ON' : 'OFF') + echo " Type annotations: " . s:State('type_annotations', 1) + echo " Operators: " . s:State('operators', 1) + echo " Function calls: " . s:State('func_calls', 0) + echo " Class vars: " . s:State('class_vars', 1) + echo " Builtins: " . s:State('builtins', 1) + echo " Exceptions: " . s:State('exceptions', 1) + echo " String format: " . s:State('string_formatting', 1) + echo " Doctests: " . s:State('doctests', 1) + echo " Space errors: " . s:State('space_errors', 0) + echo " Enhanced colors: " . (get(g:, 'python_enhanced_colors', 0) ? 'ON' : 'OFF') echo "" echo "Use :PythonSyntaxEnableAll to enable all features" endfunction diff --git a/syntax/python.vim b/syntax/python.vim index d34e74d..e160007 100644 --- a/syntax/python.vim +++ b/syntax/python.vim @@ -1,6 +1,8 @@ " Vim syntax file " Language: Python (Enhanced with Type Annotations) -" Maintainer: Aaron Carlisle / ABC-Terminal +" Maintainer: Aaron Carlisle +" Based on: Vim's syntax/python.vim by Zvezdan Petkovic, Neil Schemenauer +" and Dmitry Vasiliev " Last Change: 2026 Sep 26 " Version: 1.1.0 " @@ -21,7 +23,7 @@ " let g:python_highlight_doctests = 1 " let g:python_highlight_space_errors = 0 " let g:python_slow_sync = 0 -" let g:python_enhanced_colors = 1 " 0 = hi def link only +" let g:python_enhanced_colors = 0 " 1 = built-in palette if exists("b:current_syntax") finish @@ -34,25 +36,17 @@ set cpo&vim " Configuration " ============================================================================ -if get(g:, 'python_enhanced_highlight_all', 0) || get(g:, 'python_highlight_all', 0) - let g:python_highlight_type_annotations = 1 - let g:python_highlight_operators = 1 - let g:python_highlight_func_calls = 1 - let g:python_highlight_class_vars = 1 - let g:python_highlight_builtins = 1 - let g:python_highlight_exceptions = 1 - let g:python_highlight_string_formatting = 1 - let g:python_highlight_doctests = 1 -endif +" highlight_all turns on every option except space errors +let s:all = get(g:, 'python_enhanced_highlight_all', 0) || get(g:, 'python_highlight_all', 0) -let s:type_annotations = get(g:, 'python_highlight_type_annotations', 1) -let s:operators = get(g:, 'python_highlight_operators', 1) -let s:func_calls = get(g:, 'python_highlight_func_calls', 0) -let s:class_vars = get(g:, 'python_highlight_class_vars', 1) -let s:builtins = get(g:, 'python_highlight_builtins', 1) -let s:exceptions = get(g:, 'python_highlight_exceptions', 1) -let s:string_fmt = get(g:, 'python_highlight_string_formatting', 1) -let s:doctests = get(g:, 'python_highlight_doctests', 1) +let s:type_annotations = s:all || get(g:, 'python_highlight_type_annotations', 1) +let s:operators = s:all || get(g:, 'python_highlight_operators', 1) +let s:func_calls = s:all || get(g:, 'python_highlight_func_calls', 0) +let s:class_vars = s:all || get(g:, 'python_highlight_class_vars', 1) +let s:builtins = s:all || get(g:, 'python_highlight_builtins', 1) +let s:exceptions = s:all || get(g:, 'python_highlight_exceptions', 1) +let s:string_fmt = s:all || get(g:, 'python_highlight_string_formatting', 1) +let s:doctests = s:all || get(g:, 'python_highlight_doctests', 1) let s:space_errors = get(g:, 'python_highlight_space_errors', 0) let s:slow_sync = get(g:, 'python_slow_sync', 0) @@ -207,15 +201,16 @@ if s:type_annotations " ------------------------------------------------------------------------- " Parameter lists after def / async def (lookbehind so sync cannot drop them) - " Nested () for defaults via self-containment. + " Brackets in defaults (x=f(1, 2), x={"k": v}) are pythonBracketBlock, so + " their ) , and : cannot end the list or start an annotation. " ------------------------------------------------------------------------- syn region pythonParamList matchgroup=pythonParams \ start="\%(\%(async\s\+\)\=def\s\+\h\w*\%(\s*\[[^[\]]*\]\)\=\s*\)\@<=(" \ end=")" - \ contains=pythonParamList,pythonParamAnnotation,pythonClassVar,pythonSelfRef, - \ pythonBuiltin,pythonNumber,pythonString,pythonRawString,pythonFString, - \ pythonBytes,pythonOperatorSymbol,pythonComment,pythonEllipsis, - \ pythonDecoratorName,@Spell + \ contains=pythonBracketBlock,pythonParamAnnotation,pythonParamLambda, + \ pythonClassVar,pythonBuiltin,pythonNumber,pythonString, + \ pythonRawString,pythonFString,pythonBytes,pythonOperatorSymbol, + \ pythonComment,pythonEllipsis,@Spell \ nextgroup=pythonReturnType,pythonDefColon \ skipwhite skipnl @@ -225,6 +220,11 @@ if s:type_annotations \ start=":" end="\ze\%(\s*\%(=\|,\|)\)\)" contained \ contains=@pythonTypeExpr + " key=lambda x: ... -- the lambda's colon is not an annotation + syn region pythonParamLambda matchgroup=pythonStatement + \ start="\" matchgroup=NONE end=":" contained oneline + \ contains=pythonBracketBlock,pythonNumber,pythonString,pythonOperatorSymbol + " ------------------------------------------------------------------------- " Return type: -> through header : (not contained — any -> … : is a return type) " ------------------------------------------------------------------------- @@ -278,9 +278,10 @@ if s:type_annotations \ oneline " ------------------------------------------------------------------------- - " Type comments: # type: ... + " Type comments: # type: ... (inside pythonComment; not "# type: ignore") " ------------------------------------------------------------------------- - syn match pythonTypeComment "#\s*type:\s*.*$" contains=@pythonTypeExpr + syn match pythonTypeComment "\%(#\s*type:\s*\)\@<=\%(ignore\>\)\@!\S.*$" + \ contained contains=@pythonTypeExpr endif @@ -291,28 +292,26 @@ syn cluster pythonTypeInternal contains= \ pythonTypingType,pythonPrimitiveType,pythonTypeNone,pythonTypeEllipsis, \ pythonTypeUnion,pythonTypeComma,pythonTypeDotted,pythonTypeName, \ pythonTypeString,pythonTypeBracket,pythonTypeParen,pythonTypeParamStar, - \ pythonParamAnnotation,pythonStmtAnnotation,pythonTypeAliasEq, - \ pythonTypeAliasValue,pythonDefColon,pythonDefComment,pythonDocstring + \ pythonParamAnnotation,pythonParamLambda,pythonStmtAnnotation, + \ pythonTypeAliasEq,pythonTypeAliasValue,pythonTypeComment, + \ pythonDefColon,pythonDefComment,pythonDocstring " ============================================================================ " Class Variables (self, cls, mcs) " ============================================================================ if s:class_vars - syn keyword pythonSelfRef self cls mcs syn keyword pythonClassVar self cls mcs endif " ============================================================================ -" Operators — one match, longest alternative first; no -> ; | is bitwise here +" Operators — | is bitwise here (union only inside type regions) " ============================================================================ if s:operators - " Longest alternatives first. Literal ~ must be \~ (bare ~ is last-substitute). - " & and | are literal in magic mode; \| separates alternatives. - syn match pythonOperatorSymbol - \ "\%(<<=\|>>=\|\*\*=\|\/\/=\|:=\|+=\|-=\|\*=\|\/=\|%=\|&=\||=\|\^=\|@=\|==\|!=\|<>\|<=\|>=\|\/\/\|\*\*\|<<\|>>\|+\|-\|\*\|@\|\/\|%\|<\|>\|=\|\~\|&\||\|\^\)" - \ display + " One character at a time: runs like **= or //= look the same as a single + " match, and a character class is much cheaper than a long alternation. + syn match pythonOperatorSymbol "[-+*/%@<>=~&|^]\|!=\|:=" display endif " ============================================================================ @@ -353,7 +352,7 @@ syn cluster pythonExpression contains= \ pythonAttribute,pythonFunctionCall, \ pythonBuiltin,pythonNumber,pythonNone,pythonEllipsis, \ pythonString,pythonRawString,pythonFString,pythonBytes, - \ pythonOperatorSymbol,pythonOperator,pythonSelfRef,pythonClassVar, + \ pythonOperatorSymbol,pythonOperator,pythonClassVar, \ pythonComment,pythonDecoratorName,pythonExceptions, \ pythonFStringFieldSkip,pythonFStringDebug @@ -683,7 +682,6 @@ endif if s:class_vars hi def link pythonClassVar Identifier - hi def link pythonSelfRef Identifier endif if s:operators @@ -709,12 +707,10 @@ if s:type_annotations hi def link pythonTypeString String hi def link pythonTypeComment SpecialComment hi def link pythonTypeParamStar Operator - hi def link pythonTypeAnnotation Type hi def link pythonReturnType Type hi def link pythonParamAnnotation Type hi def link pythonStmtAnnotation Type hi def link pythonTypeAliasValue Type - hi def link pythonTypeVar Identifier endif if s:doctests diff --git a/tests/test_highlighting.py b/tests/test_highlighting.py index 4b0e6c6..4a4f656 100644 --- a/tests/test_highlighting.py +++ b/tests/test_highlighting.py @@ -31,11 +31,11 @@ # ============================================================================= -# Return Type Arrows (The Main Fix!) +# Return Type Arrows # ============================================================================= def greet(name: str) -> str: - """The -> should be highlighted as Operator, str as Type.""" + """-> is pythonReturnArrow, str is pythonPrimitiveType.""" return f"Hello, {name}" diff --git a/tests/test_loading.vim b/tests/test_loading.vim index 9c0b410..903873b 100644 --- a/tests/test_loading.vim +++ b/tests/test_loading.vim @@ -17,6 +17,12 @@ let s:prof = tempname() execute 'profile start' fnameescape(s:prof) profile file */syntax/python.vim +" Stands in for a user FileType autocmd that starts Neovim's Tree-sitter +" highlighter, which clears 'syntax'. Defined before the plugin's, like one +" in a vimrc/init.lua; only active for case 3 below. +let s:clear_syntax = 0 +autocmd FileType python if s:clear_syntax | setlocal syntax= | endif + execute 'source' fnameescape(s:root . '/plugin/python-syntax-enhanced.vim') let s:failures = [] @@ -56,6 +62,9 @@ setlocal filetype=python call s:Check(s:OursActive(), 'ours active (plugin first in rtp)') let s:loads = s:OursSourced() - s:before call s:Check(s:loads == 1, 'sourced once on FileType, got ' . s:loads) +" Palette is opt-in: by default groups keep their standard links +call s:Check(execute('highlight pythonBuiltin') =~# 'links to Function', + \ 'palette off by default') " 2. $VIMRUNTIME ahead of plugin: Vim's file wins the runtime! race, so the " plugin must force-load ours -- every time, not only the first time @@ -67,6 +76,19 @@ setlocal filetype= setlocal filetype=python call s:Check(s:OursActive(), 'ours force-loaded again after reload') +" 3. A buffer whose 'syntax' was cleared first (Tree-sitter) is left alone +let s:clear_syntax = 1 +enew +setlocal filetype=python +call s:Check(!s:OursActive(), 'not loaded when syntax was cleared') +let s:clear_syntax = 0 + +" 4. :syntax off is respected +syntax off +enew +setlocal filetype=python +call s:Check(!s:OursActive(), 'not loaded after :syntax off') + call delete(s:prof) if empty(s:failures) diff --git a/tests/test_syntax.vim b/tests/test_syntax.vim index 7ff795b..05f2330 100644 --- a/tests/test_syntax.vim +++ b/tests/test_syntax.vim @@ -5,6 +5,7 @@ let s:root = expand(':p:h:h') let &runtimepath = s:root . ',' . &runtimepath +syntax on execute 'source' fnameescape(s:root . '/plugin/python-syntax-enhanced.vim') @@ -148,6 +149,14 @@ call setline(1, [ \ ' ("a)", 1), # ] in a comment', \ ']', \ 'after_list: int = 3', + \ 'def defaults(x=foo(1, 2), y: int = 3) -> None:', + \ ' pass', + \ 'def keyed(items, key=lambda it: it.name, z: str = "") -> None:', + \ ' pass', + \ 'def mapping(x={"a": 1}, y: int = 0) -> None:', + \ ' pass', + \ 'n = 1 # type: int', + \ 'o = f() # type: ignore[attr-defined]', \ ]) setlocal filetype=python syntax sync fromstart @@ -259,6 +268,21 @@ call s:AssertGroup(92, s:ColOf(92, 'int'), 'pythonPrimitiveType', 'annotation af call s:AssertGroup(94, s:ColOf(94, 'int'), 'pythonPrimitiveType', 'annotation after (a) @ b') call s:AssertGroup(98, s:ColOf(98, 'int'), 'pythonPrimitiveType', 'annotation after list w/ ) in str') +" Brackets in parameter defaults do not end the list or start annotations +call s:AssertGroup(99, s:ColOf(99, 'int'), 'pythonPrimitiveType', 'annotation after f(1, 2) default') +call s:AssertGroup(99, s:ColOf(99, 'None'), 'pythonTypeNone', 'return type after f(1, 2) default') +call s:AssertGroup(103, s:ColOf(103, '1'), 'pythonNumber', 'dict default value not a type') +call s:AssertGroup(103, s:ColOf(103, 'int'), 'pythonPrimitiveType', 'annotation after dict default') +" lambda defaults: keyword highlighted, body is not an annotation +call s:AssertGroup(101, s:ColOf(101, 'lambda'), 'pythonStatement', 'lambda in default') +call s:AssertGroup(101, s:ColOf(101, 'it.name'), 'pythonParamList', 'lambda body not a type') +call s:AssertGroup(101, s:ColOf(101, 'str'), 'pythonPrimitiveType', 'annotation after lambda default') + +" Type comments: the type is colored, the '# type:' prefix and 'ignore' are not +call s:AssertGroup(105, s:ColOf(105, 'type'), 'pythonComment', '# type: prefix') +call s:AssertGroup(105, s:ColOf(105, 'int'), 'pythonPrimitiveType', '# type: int') +call s:AssertGroup(106, s:ColOf(106, 'ignore'), 'pythonComment', '# type: ignore') + " Palette: builtins get their own color, not the Function link call s:AssertGroup(4, s:ColOf(4, 'list'), 'pythonBuiltin', 'list() call for palette check') if synIDattr(synIDtrans(hlID('pythonBuiltin')), 'fg', 'cterm') !=# '139'