diff --git a/pdd/construct_paths.py b/pdd/construct_paths.py index 1230e7af..95548ae1 100644 --- a/pdd/construct_paths.py +++ b/pdd/construct_paths.py @@ -533,56 +533,57 @@ def _candidate_prompt_path(input_files: Dict[str, Path]) -> Path | None: # New helper function to check if a language is known -def _is_known_language(language_name: str) -> bool: - """Return True if the language is recognized. +def _get_known_languages() -> set: + """Return the set of known language names (lowercase). - Prefer CSV in PDD_PATH if available; otherwise fall back to a built-in set - so basename/language inference does not fail when PDD_PATH is unset. + Prefer CSV in PDD_PATH if available; otherwise fall back to a built-in set. """ - language_name_lower = (language_name or "").lower() - if not language_name_lower: - return False - builtin_languages = { 'python', 'javascript', 'typescript', 'typescriptreact', 'javascriptreact', 'java', 'cpp', 'c', 'go', 'ruby', 'rust', 'kotlin', 'swift', 'csharp', 'php', 'scala', 'r', 'lua', 'perl', 'bash', 'shell', 'powershell', 'sql', 'prompt', 'html', 'css', 'makefile', - # Additional languages from language_format.csv 'haskell', 'dart', 'elixir', 'clojure', 'julia', 'erlang', 'fortran', 'nim', 'ocaml', 'groovy', 'coffeescript', 'fish', 'zsh', 'prisma', 'lean', 'agda', - # Frontend / templating + 'lisp', 'scheme', 'ada', 'svelte', 'vue', 'scss', 'sass', 'less', 'jinja', 'handlebars', 'pug', 'ejs', 'twig', - # Modern / systems languages 'zig', 'mojo', 'solidity', - # Config / query / infra 'graphql', 'protobuf', 'terraform', 'hcl', 'nix', 'glsl', 'wgsl', 'starlark', 'dockerfile', - # Common data and config formats for architecture prompts and configs 'json', 'jsonl', 'yaml', 'yml', 'toml', 'ini' } pdd_path_str = os.getenv('PDD_PATH') if not pdd_path_str: - return language_name_lower in builtin_languages + return builtin_languages csv_file_path = Path(pdd_path_str) / 'data' / 'language_format.csv' if not csv_file_path.is_file(): - return language_name_lower in builtin_languages + return builtin_languages try: with open(csv_file_path, mode='r', encoding='utf-8', newline='') as csvfile: reader = csv.DictReader(csvfile) + csv_languages = set() for row in reader: - if row.get('language', '').lower() == language_name_lower: - return True + lang = row.get('language', '').strip().lower() + if lang: + csv_languages.add(lang) + return (csv_languages | builtin_languages) if csv_languages else builtin_languages except csv.Error as e: console.print(f"[error]CSV Error reading {csv_file_path}: {e}", style="error") - return language_name_lower in builtin_languages + return builtin_languages + + +def _is_known_language(language_name: str) -> bool: + """Return True if the language is recognized.""" + language_name_lower = (language_name or "").lower() + if not language_name_lower: + return False + return language_name_lower in _get_known_languages() - return language_name_lower in builtin_languages def _strip_language_suffix(path_like: os.PathLike[str]) -> str: @@ -758,7 +759,12 @@ def _determine_language( if command == "detect" and "change_file" in input_file_paths: return "prompt" - # 5 - If no language determined, raise error + # 5 - Fallback to default_language from .pddrc + default_lang = command_options.get("default_language") + if default_lang: + return default_lang.lower() + + # 6 - If no language determined, raise error raise ValueError("Could not determine language from input files or options.") diff --git a/tests/test_construct_paths.py b/tests/test_construct_paths.py index 68079c1c..7b79d341 100644 --- a/tests/test_construct_paths.py +++ b/tests/test_construct_paths.py @@ -284,7 +284,8 @@ def dynamic_get_extension(lang_candidate): with pytest.raises(ValueError) as excinfo: with patch('pdd.construct_paths.get_extension', side_effect=dynamic_get_extension), \ patch('pdd.construct_paths.get_language', return_value=None), \ - patch('pdd.construct_paths.generate_output_paths', return_value=mock_output_paths_dict_str): + patch('pdd.construct_paths.generate_output_paths', return_value=mock_output_paths_dict_str), \ + patch('pdd.construct_paths._find_pddrc_file', return_value=None): construct_paths( input_file_paths, force, quiet, command, command_options ) @@ -386,7 +387,8 @@ def mock_get_extension_func_case3(lang): def dynamic_get_ext_case4(lang): return "" # Always return "" with patch('pdd.construct_paths.get_extension', side_effect=dynamic_get_ext_case4), \ patch('pdd.construct_paths.get_language', return_value=None), \ - patch('pdd.construct_paths.generate_output_paths', return_value=mock_output_paths_dict_str): + patch('pdd.construct_paths.generate_output_paths', return_value=mock_output_paths_dict_str), \ + patch('pdd.construct_paths._find_pddrc_file', return_value=None): with pytest.raises(ValueError) as excinfo: construct_paths(input_file_paths_4, True, True, 'generate', command_options_4) assert "Could not determine language" in str(excinfo.value) @@ -695,7 +697,8 @@ def test_construct_paths_unsupported_extension_error(tmpdir): def dynamic_get_ext_unsupported(lang): return "" # Always return "" with patch('pdd.construct_paths.get_extension', side_effect=dynamic_get_ext_unsupported), \ patch('pdd.construct_paths.get_language', return_value=None), \ - patch('pdd.construct_paths.generate_output_paths'): # Mock to prevent its errors + patch('pdd.construct_paths.generate_output_paths'), \ + patch('pdd.construct_paths._find_pddrc_file', return_value=None): with pytest.raises(ValueError) as excinfo: construct_paths( input_file_paths, force, quiet, command, command_options @@ -1752,14 +1755,15 @@ def test_construct_paths_change_command_language_detection(tmpdir): # Test with a different command without language indicators with patch('pdd.construct_paths.get_extension', side_effect=lambda lang: '.py' if lang == 'python' else ''), \ patch('pdd.construct_paths.get_language', side_effect=lambda ext: 'python' if ext == '.py' else ''), \ - patch('pdd.construct_paths.generate_output_paths', return_value=mock_output_paths_dict_str): - + patch('pdd.construct_paths.generate_output_paths', return_value=mock_output_paths_dict_str), \ + patch('pdd.construct_paths._find_pddrc_file', return_value=None): + # The "generate" command should raise ValueError with no language indicators with pytest.raises(ValueError) as excinfo: _, input_strings, output_file_paths, language = construct_paths( input_file_paths_no_lang, force, quiet, "generate", command_options ) - + # The error should be about not being able to determine language assert "Could not determine language" in str(excinfo.value) @@ -1821,17 +1825,79 @@ def test_construct_paths_detect_command_language_detection(tmpdir): # Test with a different command without language indicators with patch('pdd.construct_paths.get_extension', side_effect=lambda lang: '.prompt' if lang == 'prompt' else '.py' if lang == 'python' else ''), \ patch('pdd.construct_paths.get_language', side_effect=lambda ext: 'python' if ext == '.py' else ''), \ - patch('pdd.construct_paths.generate_output_paths', return_value=mock_output_paths_dict_str): - + patch('pdd.construct_paths.generate_output_paths', return_value=mock_output_paths_dict_str), \ + patch('pdd.construct_paths._find_pddrc_file', return_value=None): + # The "generate" command should raise ValueError with no language indicators with pytest.raises(ValueError) as excinfo: _, input_strings, output_file_paths, language = construct_paths( input_file_paths_no_lang, force, quiet, "generate", command_options ) - + # The error should be about not being able to determine language assert "Could not determine language" in str(excinfo.value) + +def test_construct_paths_default_language_fallback(tmpdir): + """ + Test that _determine_language falls back to default_language from .pddrc + when no other language indicator is available (Issue #451). + """ + tmp_path = Path(str(tmpdir)) + prompt_file = tmp_path / 'test.prompt' + prompt_file.write_text('write a hello function') + + mock_output_paths = {'output': str(tmp_path / 'output.py')} + + # Case 1: default_language in command_options should be used as fallback + input_file_paths = {'prompt_file': str(prompt_file)} + command_options = {'default_language': 'python'} + with patch('pdd.construct_paths.get_extension', return_value='.py'), \ + patch('pdd.construct_paths.get_language', return_value=None), \ + patch('pdd.construct_paths.generate_output_paths', return_value=mock_output_paths), \ + patch('pdd.construct_paths._find_pddrc_file', return_value=None): + _, _, _, language = construct_paths( + input_file_paths, True, True, 'generate', command_options + ) + assert language == 'python' + + # Case 2: explicit --language flag overrides default_language + command_options_2 = {'language': 'typescript', 'default_language': 'python'} + with patch('pdd.construct_paths.get_extension', return_value='.ts'), \ + patch('pdd.construct_paths.get_language', return_value=None), \ + patch('pdd.construct_paths.generate_output_paths', return_value=mock_output_paths), \ + patch('pdd.construct_paths._find_pddrc_file', return_value=None): + _, _, _, language = construct_paths( + input_file_paths, True, True, 'generate', command_options_2 + ) + assert language == 'typescript' + + # Case 3: prompt filename suffix overrides default_language + prompt_file_js = tmp_path / 'test_javascript.prompt' + prompt_file_js.write_text('write a hello function') + input_file_paths_3 = {'prompt_file': str(prompt_file_js)} + command_options_3 = {'default_language': 'python'} + with patch('pdd.construct_paths.get_extension', side_effect=lambda l: '.js' if l == 'javascript' else '.py' if l == 'python' else ''), \ + patch('pdd.construct_paths.get_language', return_value=None), \ + patch('pdd.construct_paths.generate_output_paths', return_value=mock_output_paths), \ + patch('pdd.construct_paths._find_pddrc_file', return_value=None): + _, _, _, language = construct_paths( + input_file_paths_3, True, True, 'generate', command_options_3 + ) + assert language == 'javascript' + + # Case 4: default_language is case-insensitive + command_options_4 = {'default_language': 'Python'} + with patch('pdd.construct_paths.get_extension', return_value='.py'), \ + patch('pdd.construct_paths.get_language', return_value=None), \ + patch('pdd.construct_paths.generate_output_paths', return_value=mock_output_paths), \ + patch('pdd.construct_paths._find_pddrc_file', return_value=None): + _, _, _, language = construct_paths( + input_file_paths, True, True, 'generate', command_options_4 + ) + assert language == 'python' + + def test_construct_paths_bug_command_language_detection(tmpdir): """ Test that construct_paths correctly handles None language values for the bug command. @@ -3035,3 +3101,28 @@ def test_construct_paths_sync_mode_respects_env_prompts_dir(tmp_path, monkeypatc assert resolved_config["prompts_dir"] == "/custom/sync/prompts", \ f"Expected prompts_dir='/custom/sync/prompts' from PDD_PROMPTS_DIR in sync mode, got '{resolved_config['prompts_dir']}'" + +# --- Tests for _get_known_languages --- + +from pdd.construct_paths import _get_known_languages + + +class TestGetKnownLanguages: + """Tests for _get_known_languages helper.""" + + def test_returns_set(self): + result = _get_known_languages() + assert isinstance(result, set) + + def test_contains_common_languages(self): + known = _get_known_languages() + for lang in ['python', 'javascript', 'typescript', 'rust', 'go', 'java']: + assert lang in known + + def test_all_lowercase(self): + known = _get_known_languages() + for lang in known: + assert lang == lang.lower() + + +