Newer
Older
navi-1 / mcp-servers / navi-3d / app / scad_analyze.py
"""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`."
        )