"""lint_scad — lightweight OpenSCAD source sanity checks."""
from __future__ import annotations
import re
from pathlib import Path
from .config import Settings
_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",
)
async def lint_scad(
settings: Settings,
session_id: str,
source_path: str,
strict: bool = False,
) -> dict:
"""Lint a .scad file. Returns a dict with success, output, error, metadata."""
try:
path = settings.resolve_path(session_id, source_path)
except ValueError as exc:
return {
"success": False,
"output": str(exc),
"error": "invalid_path",
"metadata": {},
}
if not path.exists():
return {
"success": False,
"output": f"SCAD file not found: {path}",
"error": "scad_not_found",
"metadata": {},
}
if not path.is_file():
return {
"success": False,
"output": f"Path is not a file: {path}",
"error": "not_a_file",
"metadata": {},
}
if path.suffix.lower() != ".scad":
return {
"success": False,
"output": f"Expected a .scad file: {path}",
"error": "not_scad",
"metadata": {},
}
source = path.read_text(encoding="utf-8", errors="replace")
errors, warnings = _lint_scad_source(source)
failed = bool(errors or (strict and warnings))
lines = [f"SCAD lint: {path.name}"]
if not errors and not warnings:
lines.append("OK: no issues found.")
else:
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 {
"success": not failed,
"output": "\n".join(lines),
"error": "scad_lint_failed" if failed else None,
"metadata": {"errors": errors, "warnings": warnings},
}
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`."
)