"""scad_lint - lightweight OpenSCAD source sanity checks."""
from __future__ import annotations
import re
from pathlib import Path
from navi.config import settings
from navi.session_files import session_dir
from .base import Tool, ToolResult, current_session_id
_BUILTINS = {
"abs", "acos", "asin", "atan", "atan2", "ceil", "chr", "concat", "cos", "cross",
"cube", "cylinder", "difference", "echo", "else", "exp", "false", "floor", "for",
"function", "hull", "if", "import", "include", "intersection", "len", "let", "ln",
"log", "lookup", "max", "min", "mirror", "module", "norm", "PI", "polyhedron",
"pow", "projection", "rotate", "round", "scale", "search", "sign", "sin", "sphere",
"sqrt", "str", "surface", "tan", "translate", "true", "undef", "union", "use",
}
_NAMED_ARGS = {
"center", "d", "d1", "d2", "h", "r", "r1", "r2", "$fa", "$fn", "$fs",
}
_NOISY_PATTERNS = (
r"\b[Ll]et'?s\b",
r"\bActually\b",
r"\bWait\b",
r"\bhacky\b",
r"\bCorrected implementation\b",
r"\bFINAL[, ]+FINAL\b",
r"\bI promise\b",
r"\boverthinking\b",
)
class ScadLintTool(Tool):
name = "scad_lint"
description = (
"Lint an OpenSCAD (.scad) source file before compiling or publishing. "
"This is a lightweight sanity check, not a full CAD validator. It catches common "
"LLM mistakes: abandoned modules, undefined identifiers, draft/self-correction "
"comments, suspicious chamfer hacks, and parameter inconsistencies. "
"Run it after writing or editing .scad and fix errors before model_3d."
)
parameters = {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": (
"Absolute or relative path to the .scad file. Simple filenames are "
"resolved inside the current session directory."
),
},
"strict": {
"type": "boolean",
"description": "When true, warnings also make the tool fail. Default false.",
},
},
"required": ["path"],
}
async def execute(self, params: dict) -> ToolResult:
session_id = current_session_id.get()
path = self._resolve_path(params["path"], session_id)
session_error = self._validate_current_session_path(path, session_id)
if session_error:
return session_error
if not path.exists():
return ToolResult(False, f"SCAD file not found: {path}", error="scad_not_found")
if not path.is_file():
return ToolResult(False, f"Path is not a file: {path}", error="not_a_file")
if path.suffix.lower() != ".scad":
return ToolResult(False, f"Expected a .scad file: {path}", error="not_scad")
source = path.read_text(encoding="utf-8", errors="replace")
errors, warnings = lint_scad_source(source)
strict = bool(params.get("strict", False))
failed = bool(errors or (strict and warnings))
output = self._format_output(path, errors, warnings, strict)
return ToolResult(
success=not failed,
output=output,
error="scad_lint_failed" if failed else None,
metadata={"errors": errors, "warnings": warnings},
)
@staticmethod
def _resolve_path(raw_path: str, session_id: str | None) -> Path:
path = Path(raw_path).expanduser()
if session_id and not path.is_absolute() and len(path.parts) == 1:
return (session_dir(session_id) / path).resolve()
return path.resolve()
@staticmethod
def _validate_current_session_path(path: Path, session_id: str | None) -> ToolResult | None:
if not session_id:
return None
session_root = Path(settings.session_files_dir).resolve()
current_dir = session_dir(session_id).resolve()
if path.is_relative_to(session_root) and not path.is_relative_to(current_dir):
return ToolResult(
False,
(
f"path points to a different session directory: {path}\n"
f"Current session directory is: {current_dir}\n"
"Use the exact current session directory. Do not manually reconstruct "
"or retype session IDs."
),
error="wrong_session_dir",
)
return None
@staticmethod
def _format_output(path: Path, errors: list[str], warnings: list[str], strict: bool) -> str:
lines = [f"SCAD lint: {path.name}"]
if not errors and not warnings:
lines.append("OK: no issues found.")
return "\n".join(lines)
if errors:
lines.append(f"Errors ({len(errors)}):")
lines.extend(f"- {item}" for item in errors)
if warnings:
label = "Warnings as errors" if strict else "Warnings"
lines.append(f"{label} ({len(warnings)}):")
lines.extend(f"- {item}" for item in warnings)
return "\n".join(lines)
def lint_scad_source(source: str) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
code = _strip_comments_and_strings(source)
_check_noisy_source(source, errors)
_check_undefined_identifiers(code, errors)
_check_abandoned_modules(code, warnings)
_check_suspicious_geometry(source, warnings)
return errors, warnings
def _strip_comments_and_strings(source: str) -> str:
source = re.sub(r"/\*.*?\*/", " ", source, flags=re.S)
source = re.sub(r"//.*", " ", source)
source = re.sub(r'"(?:\\.|[^"\\])*"', '""', source)
return source
def _check_noisy_source(source: str, errors: list[str]) -> None:
for pattern in _NOISY_PATTERNS:
if re.search(pattern, source):
errors.append(
"Source contains draft/self-correction language "
f"matching `{pattern}`. Rewrite as clean production OpenSCAD."
)
def _check_undefined_identifiers(code: str, errors: list[str]) -> None:
declared = set(re.findall(r"\b([A-Za-z_]\w*)\s*=", code))
modules = set(re.findall(r"\bmodule\s+([A-Za-z_]\w*)\s*\(", code))
functions = set(re.findall(r"\bfunction\s+([A-Za-z_]\w*)\s*\(", code))
loop_vars = set(re.findall(r"\bfor\s*\(\s*([A-Za-z_]\w*)\s*=", code))
module_params = set()
for params in re.findall(r"\bmodule\s+[A-Za-z_]\w*\s*\(([^)]*)\)", code):
module_params.update(re.findall(r"\b([A-Za-z_]\w*)\b", params))
known = declared | modules | functions | loop_vars | module_params | _BUILTINS | _NAMED_ARGS
tokens = set(re.findall(r"\b[A-Za-z_]\w*\b", code))
unknown = sorted(t for t in tokens if t not in known)
if unknown:
errors.append("Unknown identifier(s): " + ", ".join(unknown[:20]))
def _check_abandoned_modules(code: str, warnings: list[str]) -> None:
modules = re.findall(r"\bmodule\s+([A-Za-z_]\w*)\s*\(", code)
for name in modules:
uses = len(re.findall(rf"\b{re.escape(name)}\s*\(", code))
if uses <= 1:
warnings.append(f"Module `{name}` is defined but never called.")
def _check_suspicious_geometry(source: str, warnings: list[str]) -> None:
if re.search(r"\bcube\s*\(\s*\[\s*[^]]*\+\s*chamfer_size\s*\*\s*2", source):
warnings.append(
"Suspicious chamfer construction: subtracting an oversized cube often slices "
"off a cap instead of creating edge chamfers."
)
if re.search(r"\bhole_diameter\s*/\s*2\b", source):
warnings.append(
"`hole_diameter / 2` used in geometry. If the OpenSCAD parameter is named "
"`hole_diameter`, cylinder holes should usually use `d=hole_diameter`."
)