Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 55 additions & 21 deletions python/src/hhat_lang/toolchain/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down
111 changes: 102 additions & 9 deletions python/src/hhat_lang/toolchain/project/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
69 changes: 67 additions & 2 deletions python/tests/test-cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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"])
Expand All @@ -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):
Expand Down