From f4b6debfb540d495876847464bdb051e45cb02b1 Mon Sep 17 00:00:00 2001 From: Konfekt Date: Thu, 26 Feb 2026 07:52:59 +0100 Subject: [PATCH 1/4] feat: load and parse markdown role files as roles --- README.md | 28 +++++ doc/vim-ai.txt | 22 ++++ py/utils.py | 120 ++++++++++++++++++++- tests/context_test.py | 36 +++++++ tests/resources/roles-md/markdown-image.md | 5 + tests/resources/roles-md/markdown-role.md | 6 ++ tests/roles_test.py | 18 ++++ 7 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 tests/resources/roles-md/markdown-image.md create mode 100644 tests/resources/roles-md/markdown-role.md diff --git a/README.md b/README.md index 5a29311..b683bb3 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,34 @@ Now you can select text and run it with command `:AIEdit /grammar`. You can also combine roles `:AI /o1-mini /grammar helo world!` +Alternatively, `g:vim_ai_roles_config_file` can point to a directory. +Each `*.md` file in that directory is loaded as one role, where the file name is the role name. +Use markdown frontmatter to configure role options. +The markdown body is added to `options.initial_prompt` as a `>>> system` block. + +```vim +let g:vim_ai_roles_config_file = '/path/to/my/roles' +``` + +```markdown +--- +model: openai:gpt-5.2-high +temperature: 0.3 +chat.max_tokens: 1200 +--- +fix spelling and grammar +``` + +The example above maps to role config values: + +```ini +provider = openai +options.model = gpt-5.2 +options.reasoning_effort = high +options.temperature = 0.3 +options.initial_prompt = >>> system\n\nfix spelling and grammar +``` + See [roles-example.ini](./roles-example.ini) for more examples. ## Reference diff --git a/doc/vim-ai.txt b/doc/vim-ai.txt index b5c82d5..6114351 100644 --- a/doc/vim-ai.txt +++ b/doc/vim-ai.txt @@ -275,6 +275,28 @@ Roles are defined in the `.ini` file: > let g:vim_ai_roles_config_file = '/path/to/my/roles.ini' +Alternatively, `g:vim_ai_roles_config_file` can point to a directory with +markdown files (`*.md`). Each file is loaded as one role, where file name is +the role name. Markdown frontmatter defines options and markdown body is added +to `options.initial_prompt` as `>>> system` block: > + + let g:vim_ai_roles_config_file = '/path/to/my/roles' + + --- + model: openai:gpt-5.2-high + temperature: 0.3 + chat.max_tokens: 1200 + --- + fix spelling and grammar + +This maps to role values: > + + provider = openai + options.model = gpt-5.2 + options.reasoning_effort = high + options.temperature = 0.3 + options.initial_prompt = >>> system\n\nfix spelling and grammar + Example of a role: > [grammar] diff --git a/py/utils.py b/py/utils.py index c6e946d..acaf2a5 100644 --- a/py/utils.py +++ b/py/utils.py @@ -10,6 +10,7 @@ import traceback import configparser import base64 +import re utils_py_imported = True @@ -335,6 +336,119 @@ def enhance_roles_with_custom_function(roles): else: roles.update(vim.eval(roles_config_function + "()")) +def _parse_markdown_frontmatter(content, role_file_path): + lines = content.splitlines() + if not lines or lines[0].strip() != '---': + return {}, content.strip() + + header = {} + end_index = -1 + for index, raw_line in enumerate(lines[1:], start=1): + line = raw_line.strip() + if line == '---': + end_index = index + break + if not line or line.startswith('#'): + continue + if ':' not in line: + raise Exception(f"Invalid markdown header in role file: {role_file_path}") + key, value = line.split(':', 1) + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + value = value[1:-1] + header[key.strip()] = value + + if end_index == -1: + raise Exception(f"Missing closing markdown header in role file: {role_file_path}") + + prompt = '\n'.join(lines[end_index + 1:]).strip() + return header, prompt + +def _parse_model_header_value(value): + parsed_provider = '' + parsed_model = value.strip() + + provider_match = re.match(r'^([a-zA-Z0-9_-]+):(.*)$', parsed_model) + if provider_match: + parsed_provider = provider_match.group(1) + parsed_model = provider_match.group(2).strip() + + reasoning_effort = '' + for suffix, effort in [('-high', 'high'), ('-medium', 'medium'), ('-low', 'low')]: + if parsed_model.endswith(suffix): + parsed_model = parsed_model[:-len(suffix)] + reasoning_effort = effort + break + + return parsed_provider, parsed_model, reasoning_effort + +def _make_markdown_section_name(role_name, key): + chunks = key.split('.', 1) + if len(chunks) > 1 and chunks[0] in ('chat', 'complete', 'edit', 'image'): + return f"{role_name}.{chunks[0]}", chunks[1] + return role_name, key + +def _as_system_initial_prompt(text): + text = text.strip() + if not text: + return '' + if text.startswith('>>>'): + return text + return f">>> system\n\n{text}" + +def _parse_markdown_role_file(role_name, role_file_path): + with open(role_file_path, 'r', encoding='utf-8') as file: + content = file.read() + + header, prompt = _parse_markdown_frontmatter(content, role_file_path) + sections = {} + + for key, value in header.items(): + section_name, parsed_key = _make_markdown_section_name(role_name, key) + if not section_name in sections: + sections[section_name] = {} + + if parsed_key == 'model': + provider, model, reasoning_effort = _parse_model_header_value(value) + if provider: + sections[section_name]['provider'] = provider + sections[section_name]['options.model'] = model + if reasoning_effort: + sections[section_name]['options.reasoning_effort'] = reasoning_effort + elif parsed_key == 'provider': + sections[section_name]['provider'] = value + elif parsed_key == 'prompt': + sections[section_name]['prompt'] = value + elif parsed_key.startswith('options.') or parsed_key.startswith('ui.'): + sections[section_name][parsed_key] = value + else: + sections[section_name][f"options.{parsed_key}"] = value + + if prompt: + if not role_name in sections: + sections[role_name] = {} + parsed_prompt = _as_system_initial_prompt(prompt) + existing_initial_prompt = sections[role_name].get('options.initial_prompt', '').strip() + if existing_initial_prompt: + sections[role_name]['options.initial_prompt'] = f"{existing_initial_prompt}\n\n{parsed_prompt}" + else: + sections[role_name]['options.initial_prompt'] = parsed_prompt + + return sections + +def _read_roles_from_markdown_directory(roles_dir_path): + markdown_files = sorted(glob.glob(os.path.join(roles_dir_path, '*.md'))) + markdown_files += sorted(glob.glob(os.path.join(roles_dir_path, '*.markdown'))) + + roles = {} + for role_file_path in markdown_files: + if os.path.isdir(role_file_path): + continue + role_name = os.path.splitext(os.path.basename(role_file_path))[0] + role_sections = _parse_markdown_role_file(role_name, role_file_path) + roles.update(role_sections) + return roles + def read_role_files(): plugin_root = vim.eval("s:plugin_root") default_roles_config_path = str(os.path.join(plugin_root, "roles-default.ini")) @@ -343,7 +457,11 @@ def read_role_files(): raise Exception(f"Role config file does not exist: {roles_config_path}") roles = configparser.ConfigParser() - roles.read([default_roles_config_path, roles_config_path]) + roles.read([default_roles_config_path]) + if os.path.isdir(roles_config_path): + roles.read_dict(_read_roles_from_markdown_directory(roles_config_path)) + else: + roles.read([roles_config_path]) return roles def save_b64_to_file(path, b64_data): diff --git a/tests/context_test.py b/tests/context_test.py index f8682a4..105caef 100644 --- a/tests/context_test.py +++ b/tests/context_test.py @@ -1,6 +1,10 @@ from context import make_ai_context, make_prompt from unittest.mock import patch import vim +import os + +dirname = os.path.dirname(__file__) +markdown_roles_dir = os.path.join(dirname, 'resources/roles-md') default_config = { "options": { @@ -246,3 +250,35 @@ def test_role_config_all_params(): assert actual_options['top_logprobs'] == '5' assert actual_options['top_p'] == '0.9' +def test_markdown_role_header_model_mapping(): + default_eval = vim.eval + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): + context = make_ai_context({ + 'config_default': default_config, + 'config_extension': {}, + 'user_instruction': '/markdown-role hello', + 'user_selection': '', + 'command_type': 'chat', + }) + actual_config = context['config'] + assert actual_config['provider'] == 'openai' + assert actual_config['options']['model'] == 'gpt-5.2' + assert actual_config['options']['reasoning_effort'] == 'high' + assert actual_config['options']['temperature'] == '0.3' + assert actual_config['options']['max_tokens'] == '1200' + assert actual_config['options']['initial_prompt'] == '>>> system\n\nmarkdown role prompt' + assert context['prompt'] == 'hello' + +def test_markdown_image_role_header_mapping(): + default_eval = vim.eval + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): + actual_context = make_ai_context({ + 'config_default': default_image_config, + 'config_extension': {}, + 'user_instruction': '/markdown-image describe this image', + 'user_selection': '', + 'command_type': 'image', + }) + assert actual_context['config']['provider'] == 'openai' + assert actual_context['config']['options']['model'] == 'gpt-image-1' + assert actual_context['config']['options']['size'] == '1024x1024' diff --git a/tests/resources/roles-md/markdown-image.md b/tests/resources/roles-md/markdown-image.md new file mode 100644 index 0000000..87e02c2 --- /dev/null +++ b/tests/resources/roles-md/markdown-image.md @@ -0,0 +1,5 @@ +--- +image.model: openai:gpt-image-1 +image.size: 1024x1024 +--- +image role prompt diff --git a/tests/resources/roles-md/markdown-role.md b/tests/resources/roles-md/markdown-role.md new file mode 100644 index 0000000..4a348f1 --- /dev/null +++ b/tests/resources/roles-md/markdown-role.md @@ -0,0 +1,6 @@ +--- +model: openai:gpt-5.2-high +temperature: 0.3 +chat.max_tokens: 1200 +--- +markdown role prompt diff --git a/tests/roles_test.py b/tests/roles_test.py index 3315a1a..67ddf9a 100644 --- a/tests/roles_test.py +++ b/tests/roles_test.py @@ -1,4 +1,10 @@ from roles import load_ai_role_names +import os +from unittest.mock import patch +import vim + +dirname = os.path.dirname(__file__) +markdown_roles_dir = os.path.join(dirname, 'resources/roles-md') def test_role_completion(): role_names = load_ai_role_names('complete') @@ -31,3 +37,15 @@ def test_role_chat_only(): def test_explicit_image_roles(): role_names = load_ai_role_names('image') assert set(role_names) == { 'hd-image', 'hd', 'natural' } + +def test_load_markdown_roles_from_directory(): + default_eval = vim.eval + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): + role_names = load_ai_role_names('chat') + assert 'markdown-role' in role_names + +def test_markdown_image_role_names(): + default_eval = vim.eval + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): + role_names = load_ai_role_names('image') + assert 'markdown-image' in role_names From 6dec17b903fdf437f2078f12597f3afc169cc0fd Mon Sep 17 00:00:00 2001 From: Konfekt Date: Fri, 27 Feb 2026 10:39:17 +0100 Subject: [PATCH 2/4] fix: allow '%' in role prompts by disabling ConfigParser interpolation --- py/utils.py | 4 +++- tests/context_test.py | 12 ++++++++++++ tests/resources/roles-md/markdown-role-percent.md | 4 ++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 tests/resources/roles-md/markdown-role-percent.md diff --git a/py/utils.py b/py/utils.py index acaf2a5..3237378 100644 --- a/py/utils.py +++ b/py/utils.py @@ -456,7 +456,9 @@ def read_role_files(): if not os.path.exists(roles_config_path): raise Exception(f"Role config file does not exist: {roles_config_path}") - roles = configparser.ConfigParser() + # Role prompts can contain '%' (for example "60 % shorter"), so interpolation + # must be disabled to avoid ConfigParser ValueError. + roles = configparser.ConfigParser(interpolation=None) roles.read([default_roles_config_path]) if os.path.isdir(roles_config_path): roles.read_dict(_read_roles_from_markdown_directory(roles_config_path)) diff --git a/tests/context_test.py b/tests/context_test.py index 105caef..03e89c9 100644 --- a/tests/context_test.py +++ b/tests/context_test.py @@ -282,3 +282,15 @@ def test_markdown_image_role_header_mapping(): assert actual_context['config']['provider'] == 'openai' assert actual_context['config']['options']['model'] == 'gpt-image-1' assert actual_context['config']['options']['size'] == '1024x1024' + +def test_markdown_role_prompt_with_percent_sign(): + default_eval = vim.eval + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): + context = make_ai_context({ + 'config_default': default_config, + 'config_extension': {}, + 'user_instruction': '/markdown-role-percent hello', + 'user_selection': '', + 'command_type': 'chat', + }) + assert context['config']['options']['initial_prompt'] == '>>> system\n\nRewrite with 60 % fewer words.' diff --git a/tests/resources/roles-md/markdown-role-percent.md b/tests/resources/roles-md/markdown-role-percent.md new file mode 100644 index 0000000..7f77a6e --- /dev/null +++ b/tests/resources/roles-md/markdown-role-percent.md @@ -0,0 +1,4 @@ +--- +model: openai:gpt-5.2-high +--- +Rewrite with 60 % fewer words. From 766b820fb80ca7b0adf10faffaa463bb4bcb5838 Mon Sep 17 00:00:00 2001 From: Konfekt Date: Sat, 28 Feb 2026 12:37:04 +0100 Subject: [PATCH 3/4] feat: add vim_ai_roles_config_path with directory merging and compat Implemented: - Added g:vim_ai_roles_config_path support with fallback to g:vim_ai_roles_config_file in loader logic: utils.py (./py/utils.py#L452), utils.py (./py/utils.py#L470) - Directory behavior updated: - read all *.ini files in lexicographical order (merged like one ini) - keep current markdown parsing for *.md/*.markdown - applied in utils.py (./py/ utils.py#L465) - Renamed default config variable to _path while keeping _file backward- compatible: - defaults/compat in vim_ai_config.vim (./autoload/vim_ai_config.vim#L154) - :AIUtilRolesOpen now prefers _path with _file fallback in vim_ai.vim (./autoload/vim_ai.vim#L468) - Docs updated to document _path usage and note _file compatibility: - README.md (./README.md#L145) - vim-ai.txt (./doc/vim-ai.txt#L274) - roles-example.ini (./roles- example.ini#L3) Regression tests added/updated: - Mixed directory coverage (*.ini + *.md) and _path precedence: - roles_test.py (./tests/ roles_test.py#L54) - context_test.py (./tests/ context_test.py#L299) - test fixtures: 01-base.ini (./ tests/resources/roles-mixed/01-base.ini), 02-override.ini (./tests/resources/roles-mixed/02- override.ini), mixed-md-role.md (./tests/resources/roles-mixed/mixed-md-role.md) - Mock updated for new global key: tests/mocks/vim.py (./tests/mocks/vim.py#L11) --- README.md | 12 +++--- autoload/vim_ai.vim | 2 +- autoload/vim_ai_config.vim | 9 +++- doc/vim-ai.txt | 20 +++++---- py/utils.py | 23 +++++++++- roles-example.ini | 2 +- tests/context_test.py | 44 ++++++++++++++++++-- tests/mocks/vim.py | 2 + tests/resources/roles-mixed/01-base.ini | 9 ++++ tests/resources/roles-mixed/02-override.ini | 5 +++ tests/resources/roles-mixed/mixed-md-role.md | 4 ++ tests/roles_test.py | 20 ++++++++- 12 files changed, 129 insertions(+), 23 deletions(-) create mode 100644 tests/resources/roles-mixed/01-base.ini create mode 100644 tests/resources/roles-mixed/02-override.ini create mode 100644 tests/resources/roles-mixed/mixed-md-role.md diff --git a/README.md b/README.md index b683bb3..9783352 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Do not forget to open PR updating this list. In the context of this plugin, a role means a re-usable AI instruction and/or configuration. Roles are defined in the configuration `.ini` file. For example by defining a `grammar` and `o1-mini` role: ```vim -let g:vim_ai_roles_config_file = '/path/to/my/roles.ini' +let g:vim_ai_roles_config_path = '/path/to/my/roles.ini' ``` ```ini @@ -172,13 +172,14 @@ Now you can select text and run it with command `:AIEdit /grammar`. You can also combine roles `:AI /o1-mini /grammar helo world!` -Alternatively, `g:vim_ai_roles_config_file` can point to a directory. +Alternatively, `g:vim_ai_roles_config_path` can point to a directory. +All `*.ini` files in that directory are read in lexicographical order as one combined ini file. Each `*.md` file in that directory is loaded as one role, where the file name is the role name. Use markdown frontmatter to configure role options. The markdown body is added to `options.initial_prompt` as a `>>> system` block. ```vim -let g:vim_ai_roles_config_file = '/path/to/my/roles' +let g:vim_ai_roles_config_path = '/path/to/my/roles' ``` ```markdown @@ -201,6 +202,7 @@ options.initial_prompt = >>> system\n\nfix spelling and grammar ``` See [roles-example.ini](./roles-example.ini) for more examples. +`g:vim_ai_roles_config_file` is still supported for backwards compatibility. ## Reference @@ -556,8 +558,8 @@ let g:vim_ai_image = { \ }, \} -" custom roles file location -let g:vim_ai_roles_config_file = s:plugin_root . "/roles-example.ini" +" custom roles file/directory location +let g:vim_ai_roles_config_path = s:plugin_root . "/roles-example.ini" " custom token file location let g:vim_ai_token_file_path = "~/.config/openai.token" diff --git a/autoload/vim_ai.vim b/autoload/vim_ai.vim index 1b0bf5f..2e5decd 100644 --- a/autoload/vim_ai.vim +++ b/autoload/vim_ai.vim @@ -466,7 +466,7 @@ function! vim_ai#RoleCompletionChat(A,L,P) abort endfunction function! vim_ai#AIUtilRolesOpen() abort - execute "e " . g:vim_ai_roles_config_file + execute "e " . get(g:, 'vim_ai_roles_config_path', get(g:, 'vim_ai_roles_config_file', '')) endfunction function! vim_ai#AIUtilSetDebug(is_debug) abort diff --git a/autoload/vim_ai_config.vim b/autoload/vim_ai_config.vim index 92f9d97..1d1572d 100644 --- a/autoload/vim_ai_config.vim +++ b/autoload/vim_ai_config.vim @@ -151,8 +151,15 @@ endif if !exists("g:vim_ai_token_load_fn") let g:vim_ai_token_load_fn = "" endif +if !exists("g:vim_ai_roles_config_path") + if exists("g:vim_ai_roles_config_file") + let g:vim_ai_roles_config_path = g:vim_ai_roles_config_file + else + let g:vim_ai_roles_config_path = s:plugin_root . "/roles-example.ini" + endif +endif if !exists("g:vim_ai_roles_config_file") - let g:vim_ai_roles_config_file = s:plugin_root . "/roles-example.ini" + let g:vim_ai_roles_config_file = g:vim_ai_roles_config_path endif if !exists("g:vim_ai_async_chat") let g:vim_ai_async_chat = 1 diff --git a/doc/vim-ai.txt b/doc/vim-ai.txt index 6114351..05f182b 100644 --- a/doc/vim-ai.txt +++ b/doc/vim-ai.txt @@ -273,14 +273,16 @@ ROLES Roles are defined in the `.ini` file: > - let g:vim_ai_roles_config_file = '/path/to/my/roles.ini' + let g:vim_ai_roles_config_path = '/path/to/my/roles.ini' -Alternatively, `g:vim_ai_roles_config_file` can point to a directory with -markdown files (`*.md`). Each file is loaded as one role, where file name is -the role name. Markdown frontmatter defines options and markdown body is added -to `options.initial_prompt` as `>>> system` block: > +Alternatively, `g:vim_ai_roles_config_path` can point to a directory. All +`*.ini` files are read in lexicographical order as one combined ini file. +Markdown files (`*.md`) are also loaded from the same directory. Each markdown +file is loaded as one role, where file name is the role name. Markdown +frontmatter defines options and markdown body is added to +`options.initial_prompt` as `>>> system` block: > - let g:vim_ai_roles_config_file = '/path/to/my/roles' + let g:vim_ai_roles_config_path = '/path/to/my/roles' --- model: openai:gpt-5.2-high @@ -306,10 +308,12 @@ Example of a role: > Now you can select text and run it with command `:AIEdit /grammar`. See roles-example.ini for more examples. -The roles in g:vim_ai_roles_config_file are converted to a Vim dictionary whose +The roles in g:vim_ai_roles_config_path are converted to a Vim dictionary whose labels are the names of the roles. Optionally, roles can be added by setting g:vim_ai_roles_config_function to the name of a Vimscript function returning a -dictionary of the same format as g:vim_ai_roles_config_file. +dictionary of the same format as g:vim_ai_roles_config_path. + +g:vim_ai_roles_config_file is still supported for backwards compatibility. MARKDOWN HIGHLIGHTING *g:vim_ai_chat_markdown* diff --git a/py/utils.py b/py/utils.py index 3237378..24ba36e 100644 --- a/py/utils.py +++ b/py/utils.py @@ -449,18 +449,37 @@ def _read_roles_from_markdown_directory(roles_dir_path): roles.update(role_sections) return roles +def _read_vim_global(variable_name): + try: + value = vim.eval(variable_name) + return value if value is not None else '' + except Exception: + return '' + +def _resolve_roles_config_path(): + roles_config_path = _read_vim_global("g:vim_ai_roles_config_path") + if not roles_config_path: + roles_config_path = _read_vim_global("g:vim_ai_roles_config_file") + return os.path.expanduser(roles_config_path) + +def _read_roles_from_ini_directory(roles, roles_dir_path): + ini_files = sorted(glob.glob(os.path.join(roles_dir_path, '*.ini'))) + if ini_files: + roles.read(ini_files) + def read_role_files(): plugin_root = vim.eval("s:plugin_root") default_roles_config_path = str(os.path.join(plugin_root, "roles-default.ini")) - roles_config_path = os.path.expanduser(vim.eval("g:vim_ai_roles_config_file")) + roles_config_path = _resolve_roles_config_path() if not os.path.exists(roles_config_path): - raise Exception(f"Role config file does not exist: {roles_config_path}") + raise Exception(f"Role config path does not exist: {roles_config_path}") # Role prompts can contain '%' (for example "60 % shorter"), so interpolation # must be disabled to avoid ConfigParser ValueError. roles = configparser.ConfigParser(interpolation=None) roles.read([default_roles_config_path]) if os.path.isdir(roles_config_path): + _read_roles_from_ini_directory(roles, roles_config_path) roles.read_dict(_read_roles_from_markdown_directory(roles_config_path)) else: roles.read([roles_config_path]) diff --git a/roles-example.ini b/roles-example.ini index 07b7992..d5ee6ae 100644 --- a/roles-example.ini +++ b/roles-example.ini @@ -1,6 +1,6 @@ # This is an example, do not modify/use this file!!! # Instead, configure location of your own role file: -# - let g:vim_ai_roles_config_file = '/path/to/my/roles.ini' +# - let g:vim_ai_roles_config_path = '/path/to/my/roles.ini' # .ini file structure: https://docs.python.org/3/library/configparser.html#supported-ini-file-structure diff --git a/tests/context_test.py b/tests/context_test.py index 03e89c9..d4868dc 100644 --- a/tests/context_test.py +++ b/tests/context_test.py @@ -5,6 +5,7 @@ dirname = os.path.dirname(__file__) markdown_roles_dir = os.path.join(dirname, 'resources/roles-md') +mixed_roles_dir = os.path.join(dirname, 'resources/roles-mixed') default_config = { "options": { @@ -252,7 +253,7 @@ def test_role_config_all_params(): def test_markdown_role_header_model_mapping(): default_eval = vim.eval - with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd in ('g:vim_ai_roles_config_path', 'g:vim_ai_roles_config_file') else default_eval(cmd)): context = make_ai_context({ 'config_default': default_config, 'config_extension': {}, @@ -271,7 +272,7 @@ def test_markdown_role_header_model_mapping(): def test_markdown_image_role_header_mapping(): default_eval = vim.eval - with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd in ('g:vim_ai_roles_config_path', 'g:vim_ai_roles_config_file') else default_eval(cmd)): actual_context = make_ai_context({ 'config_default': default_image_config, 'config_extension': {}, @@ -285,7 +286,7 @@ def test_markdown_image_role_header_mapping(): def test_markdown_role_prompt_with_percent_sign(): default_eval = vim.eval - with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd in ('g:vim_ai_roles_config_path', 'g:vim_ai_roles_config_file') else default_eval(cmd)): context = make_ai_context({ 'config_default': default_config, 'config_extension': {}, @@ -294,3 +295,40 @@ def test_markdown_role_prompt_with_percent_sign(): 'command_type': 'chat', }) assert context['config']['options']['initial_prompt'] == '>>> system\n\nRewrite with 60 % fewer words.' + +def test_mixed_directory_ini_files_are_merged(): + default_eval = vim.eval + with patch('vim.eval', side_effect=lambda cmd: mixed_roles_dir if cmd == 'g:vim_ai_roles_config_path' else default_eval(cmd)): + context = make_ai_context({ + 'config_default': default_config, + 'config_extension': {}, + 'user_instruction': '/mixed-ini-role hello', + 'user_selection': '', + 'command_type': 'chat', + }) + assert context['config']['options']['model'] == 'model-override' + assert context['config']['options']['token_file_path'] == '/mixed/path/ai.token' + assert context['prompt'] == 'base prompt:\nhello' + +def test_mixed_directory_uses_ini_and_markdown_roles(): + default_eval = vim.eval + with patch('vim.eval', side_effect=lambda cmd: mixed_roles_dir if cmd == 'g:vim_ai_roles_config_path' else default_eval(cmd)): + ini_context = make_ai_context({ + 'config_default': default_config, + 'config_extension': {}, + 'user_instruction': '/mixed-from-ini hello', + 'user_selection': '', + 'command_type': 'chat', + }) + assert ini_context['config']['options']['endpoint_url'] == 'https://example.com/override' + + markdown_context = make_ai_context({ + 'config_default': default_config, + 'config_extension': {}, + 'user_instruction': '/mixed-md-role hello', + 'user_selection': '', + 'command_type': 'chat', + }) + assert markdown_context['config']['provider'] == 'openai' + assert markdown_context['config']['options']['model'] == 'gpt-5.2' + assert markdown_context['config']['options']['reasoning_effort'] == 'high' diff --git a/tests/mocks/vim.py b/tests/mocks/vim.py index f2a4e43..0bea126 100644 --- a/tests/mocks/vim.py +++ b/tests/mocks/vim.py @@ -8,6 +8,8 @@ def eval(cmd): return '/tmp/vim_ai_debug.log' case 'g:vim_ai_roles_config_file': return os.path.join(dirname, '../resources/roles.ini') + case 'g:vim_ai_roles_config_path': + return os.path.join(dirname, '../resources/roles.ini') case 's:plugin_root': return os.path.abspath(os.path.join(dirname, '../..')) case 'getcwd()': diff --git a/tests/resources/roles-mixed/01-base.ini b/tests/resources/roles-mixed/01-base.ini new file mode 100644 index 0000000..9331cc4 --- /dev/null +++ b/tests/resources/roles-mixed/01-base.ini @@ -0,0 +1,9 @@ +[default] +options.token_file_path = /mixed/path/ai.token + +[mixed-ini-role] +prompt = base prompt +options.model = model-base + +[mixed-from-ini.chat] +options.endpoint_url = https://example.com/base diff --git a/tests/resources/roles-mixed/02-override.ini b/tests/resources/roles-mixed/02-override.ini new file mode 100644 index 0000000..0364aaa --- /dev/null +++ b/tests/resources/roles-mixed/02-override.ini @@ -0,0 +1,5 @@ +[mixed-ini-role] +options.model = model-override + +[mixed-from-ini.chat] +options.endpoint_url = https://example.com/override diff --git a/tests/resources/roles-mixed/mixed-md-role.md b/tests/resources/roles-mixed/mixed-md-role.md new file mode 100644 index 0000000..72b7f32 --- /dev/null +++ b/tests/resources/roles-mixed/mixed-md-role.md @@ -0,0 +1,4 @@ +--- +model: openai:gpt-5.2-high +--- +markdown role in mixed directory diff --git a/tests/roles_test.py b/tests/roles_test.py index 67ddf9a..afea4b6 100644 --- a/tests/roles_test.py +++ b/tests/roles_test.py @@ -5,6 +5,7 @@ dirname = os.path.dirname(__file__) markdown_roles_dir = os.path.join(dirname, 'resources/roles-md') +mixed_roles_dir = os.path.join(dirname, 'resources/roles-mixed') def test_role_completion(): role_names = load_ai_role_names('complete') @@ -40,12 +41,27 @@ def test_explicit_image_roles(): def test_load_markdown_roles_from_directory(): default_eval = vim.eval - with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd in ('g:vim_ai_roles_config_path', 'g:vim_ai_roles_config_file') else default_eval(cmd)): role_names = load_ai_role_names('chat') assert 'markdown-role' in role_names def test_markdown_image_role_names(): default_eval = vim.eval - with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd in ('g:vim_ai_roles_config_path', 'g:vim_ai_roles_config_file') else default_eval(cmd)): role_names = load_ai_role_names('image') assert 'markdown-image' in role_names + +def test_load_mixed_roles_from_directory_with_path_config(): + default_eval = vim.eval + with patch('vim.eval', side_effect=lambda cmd: mixed_roles_dir if cmd == 'g:vim_ai_roles_config_path' else default_eval(cmd)): + role_names = load_ai_role_names('chat') + assert 'mixed-ini-role' in role_names + assert 'mixed-from-ini' in role_names + assert 'mixed-md-role' in role_names + +def test_path_config_takes_precedence_over_file_config(): + default_eval = vim.eval + with patch('vim.eval', side_effect=lambda cmd: mixed_roles_dir if cmd == 'g:vim_ai_roles_config_path' else markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): + role_names = load_ai_role_names('chat') + assert 'mixed-md-role' in role_names + assert 'markdown-role' not in role_names From 0a939ce1800b317890e35bca780f4ca4dabd6e3e Mon Sep 17 00:00:00 2001 From: Konfekt Date: Sun, 8 Mar 2026 17:21:43 +0100 Subject: [PATCH 4/4] Revert "feat: add vim_ai_roles_config_path with directory merging and compat" This reverts commit 766b820fb80ca7b0adf10faffaa463bb4bcb5838. --- README.md | 12 +++--- autoload/vim_ai.vim | 2 +- autoload/vim_ai_config.vim | 9 +--- doc/vim-ai.txt | 20 ++++----- py/utils.py | 23 +--------- roles-example.ini | 2 +- tests/context_test.py | 44 ++------------------ tests/mocks/vim.py | 2 - tests/resources/roles-mixed/01-base.ini | 9 ---- tests/resources/roles-mixed/02-override.ini | 5 --- tests/resources/roles-mixed/mixed-md-role.md | 4 -- tests/roles_test.py | 20 +-------- 12 files changed, 23 insertions(+), 129 deletions(-) delete mode 100644 tests/resources/roles-mixed/01-base.ini delete mode 100644 tests/resources/roles-mixed/02-override.ini delete mode 100644 tests/resources/roles-mixed/mixed-md-role.md diff --git a/README.md b/README.md index 9783352..b683bb3 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Do not forget to open PR updating this list. In the context of this plugin, a role means a re-usable AI instruction and/or configuration. Roles are defined in the configuration `.ini` file. For example by defining a `grammar` and `o1-mini` role: ```vim -let g:vim_ai_roles_config_path = '/path/to/my/roles.ini' +let g:vim_ai_roles_config_file = '/path/to/my/roles.ini' ``` ```ini @@ -172,14 +172,13 @@ Now you can select text and run it with command `:AIEdit /grammar`. You can also combine roles `:AI /o1-mini /grammar helo world!` -Alternatively, `g:vim_ai_roles_config_path` can point to a directory. -All `*.ini` files in that directory are read in lexicographical order as one combined ini file. +Alternatively, `g:vim_ai_roles_config_file` can point to a directory. Each `*.md` file in that directory is loaded as one role, where the file name is the role name. Use markdown frontmatter to configure role options. The markdown body is added to `options.initial_prompt` as a `>>> system` block. ```vim -let g:vim_ai_roles_config_path = '/path/to/my/roles' +let g:vim_ai_roles_config_file = '/path/to/my/roles' ``` ```markdown @@ -202,7 +201,6 @@ options.initial_prompt = >>> system\n\nfix spelling and grammar ``` See [roles-example.ini](./roles-example.ini) for more examples. -`g:vim_ai_roles_config_file` is still supported for backwards compatibility. ## Reference @@ -558,8 +556,8 @@ let g:vim_ai_image = { \ }, \} -" custom roles file/directory location -let g:vim_ai_roles_config_path = s:plugin_root . "/roles-example.ini" +" custom roles file location +let g:vim_ai_roles_config_file = s:plugin_root . "/roles-example.ini" " custom token file location let g:vim_ai_token_file_path = "~/.config/openai.token" diff --git a/autoload/vim_ai.vim b/autoload/vim_ai.vim index 2e5decd..1b0bf5f 100644 --- a/autoload/vim_ai.vim +++ b/autoload/vim_ai.vim @@ -466,7 +466,7 @@ function! vim_ai#RoleCompletionChat(A,L,P) abort endfunction function! vim_ai#AIUtilRolesOpen() abort - execute "e " . get(g:, 'vim_ai_roles_config_path', get(g:, 'vim_ai_roles_config_file', '')) + execute "e " . g:vim_ai_roles_config_file endfunction function! vim_ai#AIUtilSetDebug(is_debug) abort diff --git a/autoload/vim_ai_config.vim b/autoload/vim_ai_config.vim index 1d1572d..92f9d97 100644 --- a/autoload/vim_ai_config.vim +++ b/autoload/vim_ai_config.vim @@ -151,15 +151,8 @@ endif if !exists("g:vim_ai_token_load_fn") let g:vim_ai_token_load_fn = "" endif -if !exists("g:vim_ai_roles_config_path") - if exists("g:vim_ai_roles_config_file") - let g:vim_ai_roles_config_path = g:vim_ai_roles_config_file - else - let g:vim_ai_roles_config_path = s:plugin_root . "/roles-example.ini" - endif -endif if !exists("g:vim_ai_roles_config_file") - let g:vim_ai_roles_config_file = g:vim_ai_roles_config_path + let g:vim_ai_roles_config_file = s:plugin_root . "/roles-example.ini" endif if !exists("g:vim_ai_async_chat") let g:vim_ai_async_chat = 1 diff --git a/doc/vim-ai.txt b/doc/vim-ai.txt index 05f182b..6114351 100644 --- a/doc/vim-ai.txt +++ b/doc/vim-ai.txt @@ -273,16 +273,14 @@ ROLES Roles are defined in the `.ini` file: > - let g:vim_ai_roles_config_path = '/path/to/my/roles.ini' + let g:vim_ai_roles_config_file = '/path/to/my/roles.ini' -Alternatively, `g:vim_ai_roles_config_path` can point to a directory. All -`*.ini` files are read in lexicographical order as one combined ini file. -Markdown files (`*.md`) are also loaded from the same directory. Each markdown -file is loaded as one role, where file name is the role name. Markdown -frontmatter defines options and markdown body is added to -`options.initial_prompt` as `>>> system` block: > +Alternatively, `g:vim_ai_roles_config_file` can point to a directory with +markdown files (`*.md`). Each file is loaded as one role, where file name is +the role name. Markdown frontmatter defines options and markdown body is added +to `options.initial_prompt` as `>>> system` block: > - let g:vim_ai_roles_config_path = '/path/to/my/roles' + let g:vim_ai_roles_config_file = '/path/to/my/roles' --- model: openai:gpt-5.2-high @@ -308,12 +306,10 @@ Example of a role: > Now you can select text and run it with command `:AIEdit /grammar`. See roles-example.ini for more examples. -The roles in g:vim_ai_roles_config_path are converted to a Vim dictionary whose +The roles in g:vim_ai_roles_config_file are converted to a Vim dictionary whose labels are the names of the roles. Optionally, roles can be added by setting g:vim_ai_roles_config_function to the name of a Vimscript function returning a -dictionary of the same format as g:vim_ai_roles_config_path. - -g:vim_ai_roles_config_file is still supported for backwards compatibility. +dictionary of the same format as g:vim_ai_roles_config_file. MARKDOWN HIGHLIGHTING *g:vim_ai_chat_markdown* diff --git a/py/utils.py b/py/utils.py index 24ba36e..3237378 100644 --- a/py/utils.py +++ b/py/utils.py @@ -449,37 +449,18 @@ def _read_roles_from_markdown_directory(roles_dir_path): roles.update(role_sections) return roles -def _read_vim_global(variable_name): - try: - value = vim.eval(variable_name) - return value if value is not None else '' - except Exception: - return '' - -def _resolve_roles_config_path(): - roles_config_path = _read_vim_global("g:vim_ai_roles_config_path") - if not roles_config_path: - roles_config_path = _read_vim_global("g:vim_ai_roles_config_file") - return os.path.expanduser(roles_config_path) - -def _read_roles_from_ini_directory(roles, roles_dir_path): - ini_files = sorted(glob.glob(os.path.join(roles_dir_path, '*.ini'))) - if ini_files: - roles.read(ini_files) - def read_role_files(): plugin_root = vim.eval("s:plugin_root") default_roles_config_path = str(os.path.join(plugin_root, "roles-default.ini")) - roles_config_path = _resolve_roles_config_path() + roles_config_path = os.path.expanduser(vim.eval("g:vim_ai_roles_config_file")) if not os.path.exists(roles_config_path): - raise Exception(f"Role config path does not exist: {roles_config_path}") + raise Exception(f"Role config file does not exist: {roles_config_path}") # Role prompts can contain '%' (for example "60 % shorter"), so interpolation # must be disabled to avoid ConfigParser ValueError. roles = configparser.ConfigParser(interpolation=None) roles.read([default_roles_config_path]) if os.path.isdir(roles_config_path): - _read_roles_from_ini_directory(roles, roles_config_path) roles.read_dict(_read_roles_from_markdown_directory(roles_config_path)) else: roles.read([roles_config_path]) diff --git a/roles-example.ini b/roles-example.ini index d5ee6ae..07b7992 100644 --- a/roles-example.ini +++ b/roles-example.ini @@ -1,6 +1,6 @@ # This is an example, do not modify/use this file!!! # Instead, configure location of your own role file: -# - let g:vim_ai_roles_config_path = '/path/to/my/roles.ini' +# - let g:vim_ai_roles_config_file = '/path/to/my/roles.ini' # .ini file structure: https://docs.python.org/3/library/configparser.html#supported-ini-file-structure diff --git a/tests/context_test.py b/tests/context_test.py index d4868dc..03e89c9 100644 --- a/tests/context_test.py +++ b/tests/context_test.py @@ -5,7 +5,6 @@ dirname = os.path.dirname(__file__) markdown_roles_dir = os.path.join(dirname, 'resources/roles-md') -mixed_roles_dir = os.path.join(dirname, 'resources/roles-mixed') default_config = { "options": { @@ -253,7 +252,7 @@ def test_role_config_all_params(): def test_markdown_role_header_model_mapping(): default_eval = vim.eval - with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd in ('g:vim_ai_roles_config_path', 'g:vim_ai_roles_config_file') else default_eval(cmd)): + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): context = make_ai_context({ 'config_default': default_config, 'config_extension': {}, @@ -272,7 +271,7 @@ def test_markdown_role_header_model_mapping(): def test_markdown_image_role_header_mapping(): default_eval = vim.eval - with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd in ('g:vim_ai_roles_config_path', 'g:vim_ai_roles_config_file') else default_eval(cmd)): + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): actual_context = make_ai_context({ 'config_default': default_image_config, 'config_extension': {}, @@ -286,7 +285,7 @@ def test_markdown_image_role_header_mapping(): def test_markdown_role_prompt_with_percent_sign(): default_eval = vim.eval - with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd in ('g:vim_ai_roles_config_path', 'g:vim_ai_roles_config_file') else default_eval(cmd)): + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): context = make_ai_context({ 'config_default': default_config, 'config_extension': {}, @@ -295,40 +294,3 @@ def test_markdown_role_prompt_with_percent_sign(): 'command_type': 'chat', }) assert context['config']['options']['initial_prompt'] == '>>> system\n\nRewrite with 60 % fewer words.' - -def test_mixed_directory_ini_files_are_merged(): - default_eval = vim.eval - with patch('vim.eval', side_effect=lambda cmd: mixed_roles_dir if cmd == 'g:vim_ai_roles_config_path' else default_eval(cmd)): - context = make_ai_context({ - 'config_default': default_config, - 'config_extension': {}, - 'user_instruction': '/mixed-ini-role hello', - 'user_selection': '', - 'command_type': 'chat', - }) - assert context['config']['options']['model'] == 'model-override' - assert context['config']['options']['token_file_path'] == '/mixed/path/ai.token' - assert context['prompt'] == 'base prompt:\nhello' - -def test_mixed_directory_uses_ini_and_markdown_roles(): - default_eval = vim.eval - with patch('vim.eval', side_effect=lambda cmd: mixed_roles_dir if cmd == 'g:vim_ai_roles_config_path' else default_eval(cmd)): - ini_context = make_ai_context({ - 'config_default': default_config, - 'config_extension': {}, - 'user_instruction': '/mixed-from-ini hello', - 'user_selection': '', - 'command_type': 'chat', - }) - assert ini_context['config']['options']['endpoint_url'] == 'https://example.com/override' - - markdown_context = make_ai_context({ - 'config_default': default_config, - 'config_extension': {}, - 'user_instruction': '/mixed-md-role hello', - 'user_selection': '', - 'command_type': 'chat', - }) - assert markdown_context['config']['provider'] == 'openai' - assert markdown_context['config']['options']['model'] == 'gpt-5.2' - assert markdown_context['config']['options']['reasoning_effort'] == 'high' diff --git a/tests/mocks/vim.py b/tests/mocks/vim.py index 0bea126..f2a4e43 100644 --- a/tests/mocks/vim.py +++ b/tests/mocks/vim.py @@ -8,8 +8,6 @@ def eval(cmd): return '/tmp/vim_ai_debug.log' case 'g:vim_ai_roles_config_file': return os.path.join(dirname, '../resources/roles.ini') - case 'g:vim_ai_roles_config_path': - return os.path.join(dirname, '../resources/roles.ini') case 's:plugin_root': return os.path.abspath(os.path.join(dirname, '../..')) case 'getcwd()': diff --git a/tests/resources/roles-mixed/01-base.ini b/tests/resources/roles-mixed/01-base.ini deleted file mode 100644 index 9331cc4..0000000 --- a/tests/resources/roles-mixed/01-base.ini +++ /dev/null @@ -1,9 +0,0 @@ -[default] -options.token_file_path = /mixed/path/ai.token - -[mixed-ini-role] -prompt = base prompt -options.model = model-base - -[mixed-from-ini.chat] -options.endpoint_url = https://example.com/base diff --git a/tests/resources/roles-mixed/02-override.ini b/tests/resources/roles-mixed/02-override.ini deleted file mode 100644 index 0364aaa..0000000 --- a/tests/resources/roles-mixed/02-override.ini +++ /dev/null @@ -1,5 +0,0 @@ -[mixed-ini-role] -options.model = model-override - -[mixed-from-ini.chat] -options.endpoint_url = https://example.com/override diff --git a/tests/resources/roles-mixed/mixed-md-role.md b/tests/resources/roles-mixed/mixed-md-role.md deleted file mode 100644 index 72b7f32..0000000 --- a/tests/resources/roles-mixed/mixed-md-role.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -model: openai:gpt-5.2-high ---- -markdown role in mixed directory diff --git a/tests/roles_test.py b/tests/roles_test.py index afea4b6..67ddf9a 100644 --- a/tests/roles_test.py +++ b/tests/roles_test.py @@ -5,7 +5,6 @@ dirname = os.path.dirname(__file__) markdown_roles_dir = os.path.join(dirname, 'resources/roles-md') -mixed_roles_dir = os.path.join(dirname, 'resources/roles-mixed') def test_role_completion(): role_names = load_ai_role_names('complete') @@ -41,27 +40,12 @@ def test_explicit_image_roles(): def test_load_markdown_roles_from_directory(): default_eval = vim.eval - with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd in ('g:vim_ai_roles_config_path', 'g:vim_ai_roles_config_file') else default_eval(cmd)): + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): role_names = load_ai_role_names('chat') assert 'markdown-role' in role_names def test_markdown_image_role_names(): default_eval = vim.eval - with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd in ('g:vim_ai_roles_config_path', 'g:vim_ai_roles_config_file') else default_eval(cmd)): + with patch('vim.eval', side_effect=lambda cmd: markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): role_names = load_ai_role_names('image') assert 'markdown-image' in role_names - -def test_load_mixed_roles_from_directory_with_path_config(): - default_eval = vim.eval - with patch('vim.eval', side_effect=lambda cmd: mixed_roles_dir if cmd == 'g:vim_ai_roles_config_path' else default_eval(cmd)): - role_names = load_ai_role_names('chat') - assert 'mixed-ini-role' in role_names - assert 'mixed-from-ini' in role_names - assert 'mixed-md-role' in role_names - -def test_path_config_takes_precedence_over_file_config(): - default_eval = vim.eval - with patch('vim.eval', side_effect=lambda cmd: mixed_roles_dir if cmd == 'g:vim_ai_roles_config_path' else markdown_roles_dir if cmd == 'g:vim_ai_roles_config_file' else default_eval(cmd)): - role_names = load_ai_role_names('chat') - assert 'mixed-md-role' in role_names - assert 'markdown-role' not in role_names