diff --git a/python/src/hhat_lang/toolchain/cli/cli.py b/python/src/hhat_lang/toolchain/cli/cli.py index bb1bb3c1..29c3f113 100644 --- a/python/src/hhat_lang/toolchain/cli/cli.py +++ b/python/src/hhat_lang/toolchain/cli/cli.py @@ -142,32 +142,58 @@ def new( ) raise typer.Exit(1) else: - file_path = Path(file_name) - if (proj_dir / f"{file_path}.hat").is_file(): - raise FileExistsError(f"File {file_path}.hat already exists") - if file_path.parent != Path("."): - file_path.parent.mkdir(parents=True, exist_ok=False) - create_new_file(proj_dir, f"{file_path}.hat") - console.print( - Panel( - f"File [bold]{file_name}.hat[/bold] created successfully!", - title="✓ Success", - border_style="green", - ) + # keep the user's path but normalize for extension when + # reporting success. Do a light pre-check for existing files + # using the default src/ location. + orig_path = Path(file_name) + display_path = ( + orig_path + if orig_path.suffix == ".hat" + else orig_path.with_suffix(orig_path.suffix + ".hat") ) + check_path = orig_path + if check_path.parts[:1] != ("src",): + check_path = Path("src") / check_path + if check_path.suffix != ".hat": + check_path = check_path.with_suffix(check_path.suffix + ".hat") + + if (proj_dir / check_path).is_file(): + raise FileExistsError(f"File {check_path} already exists") + try: + # pass the original path so create_new_file can handle + # src/ defaults and validation + create_new_file(proj_dir, orig_path) + except (FileNotFoundError, ValueError) as e: + console.print( + Panel( + str(e) + + "\n\nPlease make sure you're inside a H-hat project directory.", + title="⚠ Error", + border_style="red", + ) + ) + raise typer.Exit(1) + else: + console.print( + Panel( + f"File [bold]{display_path}[/bold] created successfully!", + title="✓ Success", + border_style="green", + ) + ) + elif type_file: proj_dir = get_proj_dir() + type_path = Path(type_file) + display_type = ( + type_path + if type_path.suffix == ".hat" + else type_path.with_suffix(type_path.suffix + ".hat") + ) try: - create_new_type_file(proj_dir, Path(type_file)) - console.print( - Panel( - f"Type file [bold]{type_file}.hat[/bold] created successfully!", - title="✓ Success", - border_style="green", - ) - ) - except ValueError as e: + create_new_type_file(proj_dir, type_path) + except (FileNotFoundError, ValueError) as e: console.print( Panel( str(e) @@ -177,6 +203,14 @@ def new( ) ) raise typer.Exit(1) + else: + console.print( + Panel( + f"Type file [bold]{display_type}[/bold] created successfully!", + title="✓ Success", + border_style="green", + ) + ) else: console.print( diff --git a/python/src/hhat_lang/toolchain/project/new.py b/python/src/hhat_lang/toolchain/project/new.py index 5c0ae36e..5f555002 100644 --- a/python/src/hhat_lang/toolchain/project/new.py +++ b/python/src/hhat_lang/toolchain/project/new.py @@ -54,18 +54,111 @@ def _create_template_files(project_name: Path) -> Any: def create_new_file(project_name: str | Path, file_name: str | Path) -> Any: + """Create a new ``.hat`` source file and mirrored documentation file. + + If ``file_name`` is not under ``src/`` it is placed there automatically. + Documentation is written to ``src/hat_docs/`` mirroring the source path, + and the ``.hat`` extension is appended if missing. + """ + project_name = str_to_path(project_name) - file_name = str_to_path(file_name) - doc_file = file_name.parent / "hat_docs" / (file_name.name + ".md") + if not project_name.is_dir(): + raise FileNotFoundError(f"Project directory '{project_name}' not found.") + + file_path = Path(file_name) + # ensure the file has a `.hat` extension + if file_path.suffix != ".hat": + file_path = file_path.with_suffix(file_path.suffix + ".hat") + + source_full_path = ( + (project_name / file_path).resolve() + if not file_path.is_absolute() + else file_path.resolve() + ) + + if not _is_project_scope(project_name, source_full_path): + raise ValueError("The target file path is outside the project directory.") + + rel_path = source_full_path.relative_to(project_name) + if rel_path.parts[:1] != ("src",): + rel_path = Path("src") / rel_path + source_full_path = project_name / rel_path - open(project_name / file_name, "w").close() - open(project_name / doc_file, "w").close() + docs_rel = rel_path.relative_to("src").with_suffix(rel_path.suffix + ".md") + docs_full_path = project_name / "src" / "hat_docs" / docs_rel + + source_full_path.parent.mkdir(parents=True, exist_ok=True) + docs_full_path.parent.mkdir(parents=True, exist_ok=True) + + if source_full_path.exists() or docs_full_path.exists(): + raise FileExistsError(f"File {source_full_path} or its docs already exists") + + try: + open(source_full_path, "x").close() + open(docs_full_path, "x").close() + except OSError as exc: + raise OSError(f"Could not create new file: {exc}") def create_new_type_file(project_name: str | Path, file_name: str | Path) -> Any: - project_name = str_to_path(project_name) - file_name = str_to_path(file_name) - doc_file = file_name.parent / (file_name.name + ".md") + """Create a new ``.hat`` type file and mirrored documentation. + + The type source is placed under ``src/hat_types`` (if not already), and + documentation is written under ``src/hat_docs/hat_types`` mirroring the + relative path. The ``.hat`` extension is appended if missing. + """ - open(project_name / "src" / "hat_types" / file_name, "w").close() - open(project_name / "src" / "hat_docs" / "hat_types" / doc_file, "w").close() + project_name = str_to_path(project_name) + if not project_name.is_dir(): + raise FileNotFoundError(f"Project directory '{project_name}' not found.") + + file_path = Path(file_name) + + # Check the original path first to avoid paths like "../bad" + pre_full = ( + (project_name / file_path).resolve() + if not file_path.is_absolute() + else file_path.resolve() + ) + + if not _is_project_scope(project_name, pre_full): + raise ValueError("The target file path is outside the project directory.") + + if not file_path.is_absolute(): + if file_path.parts[:2] == ("src", "hat_types"): + pass + else: + if file_path.parts[:1] == ("src",): + file_path = Path(*file_path.parts[1:]) + file_path = Path("src") / "hat_types" / file_path + + if file_path.suffix != ".hat": + file_path = file_path.with_suffix(".hat") + + source_full_path = ( + (project_name / file_path).resolve() + if not file_path.is_absolute() + else file_path.resolve() + ) + + rel_path = source_full_path.relative_to(project_name) + if rel_path.parts[:2] != ("src", "hat_types"): + rel_path = Path("src") / "hat_types" / rel_path + source_full_path = project_name / rel_path + + docs_rel = rel_path.relative_to("src").with_suffix(rel_path.suffix + ".md") + docs_full_path = project_name / "src" / "hat_docs" / docs_rel + + source_full_path.parent.mkdir(parents=True, exist_ok=True) + docs_full_path.parent.mkdir(parents=True, exist_ok=True) + + if source_full_path.exists() or docs_full_path.exists(): + raise FileExistsError( + f"Type file {source_full_path} or its docs already exists" + ) + + try: + open(source_full_path, "x").close() + open(docs_full_path, "x").close() + except OSError as exc: + raise OSError(f"Could not create new type file: {exc}") diff --git a/python/tests/test-cli.py b/python/tests/test-cli.py index c11249bc..e4267f11 100644 --- a/python/tests/test-cli.py +++ b/python/tests/test-cli.py @@ -49,6 +49,7 @@ def test_create_new_project(temp_dir): assert "created successfully" in result.stdout assert (Path() / "testproject").exists() assert (Path() / "testproject" / "src" / "main.hat").exists() + assert (Path() / "testproject" / "src" / "hat_docs" / "main.hat.md").exists() def test_create_project_exists(temp_dir): @@ -69,7 +70,30 @@ def test_create_file_in_project(temp_dir): result = runner.invoke(app, ["new", "-f", "module/testfile"]) assert result.exit_code == 0 assert "created successfully" in result.stdout - assert (Path() / "module" / "testfile.hat").exists() + assert (Path() / "src" / "module" / "testfile.hat").exists() + assert not (Path() / "src" / "module" / "testfile").exists() + assert (Path() / "src" / "hat_docs" / "module" / "testfile.hat.md").exists() + + +def test_create_file_default_under_src(temp_dir): + """Creating a file with no path goes into src/ by default""" + runner.invoke(app, ["new", "proj"]) + os.chdir("proj") + result = runner.invoke(app, ["new", "-f", "simple"]) + assert result.exit_code == 0 + assert (Path() / "src" / "simple.hat").exists() + assert (Path() / "src" / "hat_docs" / "simple.hat.md").exists() + + +def test_create_file_with_src_prefix_and_extension(temp_dir): + """Specifying a src/ path with .hat should not duplicate the extension""" + runner.invoke(app, ["new", "proj"]) + os.chdir("proj") + result = runner.invoke(app, ["new", "-f", "src/foo.hat"]) + assert result.exit_code == 0 + assert (Path() / "src" / "foo.hat").exists() + assert not (Path() / "src" / "foo.hat.hat").exists() + assert (Path() / "src" / "hat_docs" / "foo.hat.md").exists() def test_create_file_outside_project(temp_dir): @@ -80,6 +104,15 @@ def test_create_file_outside_project(temp_dir): assert "project directory" in result.stdout +def test_create_file_escape_scope(temp_dir): + """Creating a file with a path outside project should fail""" + runner.invoke(app, ["new", "proj"]) + os.chdir("proj") + result = runner.invoke(app, ["new", "-f", "../bad"]) + assert result.exit_code == 1 + assert "outside the project directory" in result.stdout + + def test_create_existing_file(temp_dir): """Test creating a file fails when it already exists""" runner.invoke(app, ["new", "testproject"]) @@ -98,7 +131,39 @@ def test_create_type_file(temp_dir): result = runner.invoke(app, ["new", "-t", "customtype"]) assert result.exit_code == 0 assert "created successfully" in result.stdout - assert "customtype.hat" in result.stdout + assert (Path() / "src" / "hat_types" / "customtype.hat").exists() + assert (Path() / "src" / "hat_docs" / "hat_types" / "customtype.hat.md").exists() + + +def test_create_type_file_with_full_path(temp_dir): + """Type file path including src/hat_types should be mirrored""" + runner.invoke(app, ["new", "proj"]) + os.chdir("proj") + result = runner.invoke(app, ["new", "-t", "src/hat_types/subdir/foo"]) + assert result.exit_code == 0 + assert (Path() / "src" / "hat_types" / "subdir" / "foo.hat").exists() + assert ( + Path() / "src" / "hat_docs" / "hat_types" / "subdir" / "foo.hat.md" + ).exists() + + +def test_create_type_file_src_prefix(temp_dir): + """If path starts with src/ but not hat_types/, it is placed under hat_types""" + runner.invoke(app, ["new", "proj"]) + os.chdir("proj") + result = runner.invoke(app, ["new", "-t", "src/foo"]) + assert result.exit_code == 0 + assert (Path() / "src" / "hat_types" / "foo.hat").exists() + assert (Path() / "src" / "hat_docs" / "hat_types" / "foo.hat.md").exists() + + +def test_create_type_file_escape_scope(temp_dir): + """Creating a type file outside project should fail""" + runner.invoke(app, ["new", "proj"]) + os.chdir("proj") + result = runner.invoke(app, ["new", "-t", "../bad"]) + assert result.exit_code == 1 + assert "outside the project directory" in result.stdout def test_run_project(temp_dir):