diff --git a/CHANGELOG.md b/CHANGELOG.md index 1604527..63bc0fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ below was added since 2.1.4, the notebook runtime of 2.2.0b1 included. ### Added +- Editor: code completion from the SDK itself. `scad.` (and `scad.ql.`, `scad.std.gear.`, `ql.`) + lists the real functions with their signatures and docstrings; accepting one, or typing `(` + after a known name, writes the call with every required parameter as `name=` and Tab moves + between them; inside a call the parameters not given yet complete as `name=` (required first). + The names come from the running package (`GET /api/sdk`), so they cannot drift from the code. - Modeling: `scale`, affine `transform`, signed `offset`, `draft`, tapered and until-limited extrusions and multisection sweeps are recorded features, and boolean results can keep every output of a set operation; graph nodes keep diff --git a/editor-web/package-lock.json b/editor-web/package-lock.json index 378d381..64fa773 100644 --- a/editor-web/package-lock.json +++ b/editor-web/package-lock.json @@ -8,8 +8,10 @@ "name": "simplecad-editor-web", "version": "0.0.0", "dependencies": { + "@codemirror/autocomplete": "^6.20.3", "@codemirror/lang-python": "^6.2.1", "@codemirror/language": "^6.13.1", + "@codemirror/state": "^6.7.6", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.43.14", "@lezer/highlight": "^1.2.5", diff --git a/editor-web/package.json b/editor-web/package.json index ab30b07..2b2afbd 100644 --- a/editor-web/package.json +++ b/editor-web/package.json @@ -11,8 +11,10 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.3", "@codemirror/lang-python": "^6.2.1", "@codemirror/language": "^6.13.1", + "@codemirror/state": "^6.7.6", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.43.14", "@lezer/highlight": "^1.2.5", diff --git a/editor-web/src/components/CellEditor.tsx b/editor-web/src/components/CellEditor.tsx index f1581cb..7b87b23 100644 --- a/editor-web/src/components/CellEditor.tsx +++ b/editor-web/src/components/CellEditor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import CodeMirror from "@uiw/react-codemirror"; import { python } from "@codemirror/lang-python"; import { EditorView } from "@codemirror/view"; @@ -20,6 +20,7 @@ import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { ParamControl, type ParamMeta, type ParamValue } from "@/components/ParamControl"; import { cadTheme } from "@/lib/cm-theme"; +import { sdkCompletion } from "@/lib/sdk-complete"; import { cn } from "@/lib/utils"; export type NotebookCell = { @@ -49,7 +50,9 @@ export type { ParamMeta, ParamValue }; type SocketLike = { send: (message: Record) => void } | null; const AUTOSAVE_MS = 700; -const EXTENSIONS = [python(), EditorView.lineWrapping]; +const EXTENSIONS = [python(), EditorView.lineWrapping, sdkCompletion]; +/** a constant: a new object each render makes the editor wrapper rebuild its configuration, which drops a snippet in progress */ +const BASIC_SETUP = { lineNumbers: false, foldGutter: false, highlightActiveLine: false }; function loadCollapsed(notebook: string | null): Set { try { @@ -317,6 +320,10 @@ function CellBlock({ onDelete: () => void; onEdit: (value: string) => void; }) { + // the editor takes this once: a new function each render would rebuild its configuration + const onEditRef = useRef(onEdit); + onEditRef.current = onEdit; + const handleChange = useCallback((value: string) => onEditRef.current(value), []); const failed = cell.status === "exception" || cell.status === "error" || (cell.errors?.length ?? 0) > 0; const stale = cell.status === "stale"; const dirty = draft !== undefined && draft !== cell.code; @@ -411,8 +418,8 @@ function CellBlock({ extensions={EXTENSIONS} theme={cadTheme} editable={!cell.readonly} - basicSetup={{ lineNumbers: false, foldGutter: false, highlightActiveLine: false }} - onChange={onEdit} + basicSetup={BASIC_SETUP} + onChange={handleChange} onKeyDown={(event) => { if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { event.preventDefault(); diff --git a/editor-web/src/components/ModuleView.tsx b/editor-web/src/components/ModuleView.tsx index 4dc7c04..2f12221 100644 --- a/editor-web/src/components/ModuleView.tsx +++ b/editor-web/src/components/ModuleView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import CodeMirror from "@uiw/react-codemirror"; import { python } from "@codemirror/lang-python"; import { EditorView } from "@codemirror/view"; @@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { ParamControl, type ParamMeta, type ParamValue } from "@/components/ParamControl"; import { cadTheme } from "@/lib/cm-theme"; +import { sdkCompletion } from "@/lib/sdk-complete"; /** A local module of the workspace (shared dimensions, helpers): what the * server sends as the `module` envelope. */ @@ -21,7 +22,9 @@ export type ModuleState = { }; const AUTOSAVE_MS = 700; -const EXTENSIONS = [python(), EditorView.lineWrapping]; +const EXTENSIONS = [python(), EditorView.lineWrapping, sdkCompletion]; +/** a constant: a new object each render makes the editor wrapper rebuild its configuration, which drops a snippet in progress */ +const BASIC_SETUP = { lineNumbers: true, foldGutter: false, highlightActiveLine: false }; /** A module open next to the notebooks: its dataclass fields as controls, its * source below. Every edit is written to the file at once, and every notebook @@ -60,6 +63,11 @@ export function ModuleView({ }, AUTOSAVE_MS); }; + // the editor takes this once: a new function each render would rebuild its configuration + const editRef = useRef(edit); + editRef.current = edit; + const handleChange = useCallback((value: string) => editRef.current(value), []); + const forward = (id: string, value: ParamValue) => { pending.current.set(id, value); if (paramTimer.current !== null) return; @@ -124,8 +132,8 @@ export function ModuleView({ height="auto" extensions={EXTENSIONS} theme={cadTheme} - basicSetup={{ lineNumbers: true, foldGutter: false, highlightActiveLine: false }} - onChange={edit} + basicSetup={BASIC_SETUP} + onChange={handleChange} /> diff --git a/editor-web/src/lib/cm-theme.ts b/editor-web/src/lib/cm-theme.ts index 8ca5599..9df73ae 100644 --- a/editor-web/src/lib/cm-theme.ts +++ b/editor-web/src/lib/cm-theme.ts @@ -22,10 +22,29 @@ const theme = EditorView.theme( color: "var(--popover-foreground)", border: "2px solid var(--border)", }, + ".cm-tooltip-autocomplete": { fontSize: "12px" }, + ".cm-tooltip-autocomplete > ul": { fontFamily: "var(--mono)", maxHeight: "16em" }, + ".cm-tooltip-autocomplete ul li": { padding: "2px 8px" }, ".cm-tooltip-autocomplete ul li[aria-selected]": { backgroundColor: "var(--primary)", color: "var(--primary-foreground)", }, + ".cm-completionDetail": { color: "var(--muted-foreground)", fontStyle: "normal", marginLeft: "1em" }, + ".cm-tooltip-autocomplete ul li[aria-selected] .cm-completionDetail": { color: "inherit", opacity: "0.75" }, + ".cm-completionMatchedText": { textDecoration: "none", color: "var(--focus)", fontWeight: "700" }, + ".cm-tooltip-autocomplete ul li[aria-selected] .cm-completionMatchedText": { color: "inherit" }, + ".cm-completionIcon": { opacity: "0.6" }, + ".cm-completionInfo": { + backgroundColor: "var(--popover)", + color: "var(--popover-foreground)", + border: "2px solid var(--border)", + padding: "6px 8px", + maxWidth: "420px", + }, + ".sca-api-signature": { color: "var(--name)", wordBreak: "break-word" }, + ".sca-api-doc": { marginTop: "4px", color: "var(--muted-foreground)" }, + // the parameter to fill in next + ".cm-snippetField": { backgroundColor: "var(--code-selection)", outline: "1px solid var(--focus)" }, }, { dark: false }, ); diff --git a/editor-web/src/lib/sdk-complete.ts b/editor-web/src/lib/sdk-complete.ts new file mode 100644 index 0000000..756b8f8 --- /dev/null +++ b/editor-web/src/lib/sdk-complete.ts @@ -0,0 +1,208 @@ +import { acceptCompletion, snippet, type Completion, type CompletionContext, type CompletionResult } from "@codemirror/autocomplete"; +import { pythonLanguage } from "@codemirror/lang-python"; +import { Prec, type Extension } from "@codemirror/state"; +import { EditorView, keymap } from "@codemirror/view"; + +/** + * Code completion from the SDK itself. The server describes the running package (GET /api/sdk: + * every public function and class with its parameters); from that: + * + * - `scad.` lists the functions (also `scad.ql.`, `scad.std.gear.`, and `ql.` / `Q.`); + * - accepting one, or typing `(` after a known name, writes the call with every required + * parameter as `name=` and moves between them with Tab; + * - inside a call, the parameters not given yet complete as `name=` (required ones first). + */ + +interface Param { + n: string; + r: boolean; + /** "p" positional only, "a" the *args parameter, "w" the **kwargs one, "" an ordinary one */ + k: "p" | "a" | "w" | ""; + d?: string; +} +interface Entry { + n: string; + k: "f" | "c"; + p: Param[]; + d: string; +} +interface SdkIndex { + namespaces: Record; + submodules: Record; +} + +let index: SdkIndex | null = null; +let loading: Promise | null = null; +const load = (): Promise => + (loading ??= fetch("/api/sdk") + .then((response) => (response.ok ? (response.json() as Promise) : null)) + .then((data) => (index = data)) + .catch(() => null)); + +/** how a name written in a cell reaches a namespace of the index */ +const HEADS: Record = { scad: "scad", ql: "scad.ql", Q: "scad.ql" }; +const namespaceOf = (path: string): string | null => { + const [head, ...rest] = path.split("."); + const base = HEADS[head]; + return base ? [base, ...rest].join(".") : null; +}; + +/** `scad.ql.faces` before the cursor: the namespace path and the name being typed */ +const MEMBER = /(? { + const space = namespaceOf(path); + return (space && index?.namespaces[space]?.find((entry) => entry.n === name)) || null; +}; + +const escapeSnippet = (text: string) => text.replace(/[\\${}]/g, "\\$&"); + +/** `(width=${1}, height=${2})`: every parameter that has to be written, in order */ +export const callTemplate = (entry: Entry): string => { + let slot = 0; + const args = entry.p + .filter((param) => param.k !== "w" && (param.r || param.k === "a")) + .map((param) => { + slot += 1; + // positional ones cannot be named: a hint the person overwrites + return param.k === "p" || param.k === "a" ? `\${${slot}:${escapeSnippet(param.n)}}` : `${param.n}=\${${slot}}`; + }); + return `(${args.length ? args.join(", ") : "${}"})`; +}; + +const signature = (entry: Entry): string => { + const required = entry.p.filter((param) => param.r || param.k === "a").map((param) => (param.k === "a" ? `*${param.n}` : param.n)); + const more = entry.p.some((param) => !param.r && param.k !== "a" && param.k !== "w"); + return `(${required.join(", ")}${more ? (required.length ? ", …" : "…") : ""})`; +}; + +const info = (entry: Entry) => () => { + const root = document.createElement("div"); + const call = document.createElement("div"); + call.className = "sca-api-signature"; + call.textContent = + entry.n + + "(" + + entry.p.map((param) => (param.k === "a" ? "*" : param.k === "w" ? "**" : "") + param.n + (param.d !== undefined ? `=${param.d}` : "")).join(", ") + + ")"; + root.append(call); + if (entry.d) { + const doc = document.createElement("div"); + doc.className = "sca-api-doc"; + doc.textContent = entry.d; + root.append(doc); + } + return root; +}; + +const entryCompletion = (entry: Entry): Completion => ({ + label: entry.n, + type: entry.k === "c" ? "class" : "function", + detail: signature(entry), + info: info(entry), + apply: (view, completion, from, to) => { + // editing an existing call: only the name + if (view.state.sliceDoc(to, to + 1) === "(") { + view.dispatch({ changes: { from, to, insert: entry.n }, selection: { anchor: from + entry.n.length } }); + return; + } + snippet(`${escapeSnippet(entry.n)}${callTemplate(entry)}`)(view, completion, from, to); + }, +}); + +/** `scad.` + the typed start of a name */ +const memberSource = async (context: CompletionContext): Promise => { + const line = context.state.doc.lineAt(context.pos); + const match = MEMBER.exec(line.text.slice(0, context.pos - line.from)); + if (!match) return null; + const space = namespaceOf(match[1]); + if (!space) return null; + const data = index ?? (await load()); + if (!data || (!data.namespaces[space] && !data.submodules[space])) return null; + const options: Completion[] = [ + ...(data.namespaces[space] ?? []).map(entryCompletion), + ...(data.submodules[space] ?? []).map((name): Completion => ({ label: name, type: "namespace", detail: "module" })), + ]; + return { from: context.pos - match[2].length, options, validFor: /^\w*$/ }; +}; + +/** the innermost call the cursor is in: its function, and what is written between the parentheses */ +const enclosingCall = (text: string, pos: number): { path: string; name: string; args: string } | null => { + let depth = 0; + for (let i = pos - 1; i >= Math.max(0, pos - 4000); i--) { + const ch = text[i]; + if (ch === ")" || ch === "]" || ch === "}") depth++; + else if (ch === "(" || ch === "[" || ch === "{") { + if (depth > 0) depth--; + else if (ch !== "(") return null; + else { + const named = /(? { + let depth = 0; + let out = ""; + for (const ch of args) { + if (ch === "(" || ch === "[" || ch === "{") depth++; + else if (ch === ")" || ch === "]" || ch === "}") depth = Math.max(0, depth - 1); + else if (depth === 0) out += ch; + } + return out; +}; + +/** inside `scad.f(`: the parameters not given yet, as `name=` */ +const kwargSource = async (context: CompletionContext): Promise => { + const word = context.matchBefore(/\w*/); + if (!word || (word.from === word.to && !context.explicit)) return null; + const text = context.state.sliceDoc(0, context.pos); + const call = enclosingCall(text, context.pos); + if (!call) return null; + const data = index ?? (await load()); + if (!data) return null; + const entry = lookup(call.path, call.name); + if (!entry) return null; + const top = topLevel(call.args); + const segments = top.split(","); + // only where an argument starts: not after `name=` and not inside an expression + if (!/^\s*\w*$/.test(segments[segments.length - 1])) return null; + const given = new Set([...top.matchAll(/(?])\b([A-Za-z_]\w*)\s*=(?!=)/g)].map((m) => m[1])); + const positional = segments.slice(0, -1).filter((segment) => segment.trim() && !/(?])=(?!=)/.test(segment)).length; + const free = entry.p.filter((param) => param.k !== "a" && param.k !== "w"); + const options: Completion[] = free + .filter((param, k) => !given.has(param.n) && k >= positional && param.k !== "p") + .map((param) => ({ + label: `${param.n}=`, + type: "property", + detail: param.r ? "required" : param.d !== undefined ? `default ${param.d}` : "", + // above Python's own names (bool, ...): inside this call these are what is wanted + boost: param.r ? 99 : 50, + })); + return options.length ? { from: word.from, options, validFor: /^\w*$/ } : null; +}; + +/** `(` typed after a known function: write the call with its required parameters */ +const parenthesis = EditorView.inputHandler.of((view, from, to, text) => { + if (text !== "(" || from !== to || !index) return false; + const line = view.state.doc.lineAt(from); + const match = MEMBER.exec(line.text.slice(0, from - line.from)); + const entry = match && match[2] ? lookup(match[1], match[2]) : null; + if (!entry || view.state.sliceDoc(to, to + 1) === "(") return false; + snippet(callTemplate(entry))(view, null, from, to); + return true; +}); + +export const sdkCompletion: Extension = [ + pythonLanguage.data.of({ autocomplete: memberSource }), + pythonLanguage.data.of({ autocomplete: kwargSource }), + Prec.high(parenthesis), + // Tab takes the highlighted suggestion (else it moves to the next parameter of a call) + Prec.highest(keymap.of([{ key: "Tab", run: acceptCompletion }])), +]; + +void load(); diff --git a/src/simplecadapi/editor/apiindex.py b/src/simplecadapi/editor/apiindex.py new file mode 100644 index 0000000..d811e5d --- /dev/null +++ b/src/simplecadapi/editor/apiindex.py @@ -0,0 +1,120 @@ +"""The SDK's public API as plain data, for the editor's code completion. + +The cell editor completes ``scad.``, and a call's parameters as ``name=`` placeholders, from +this index. It is read from the live package (``inspect``), so it is always the API of the +version that is running and cannot drift from the code. Namespaces are the places a notebook +reaches names through: ``scad`` (the package), ``scad.ql`` and ``scad.std.``. +""" + +from __future__ import annotations + +import functools +import inspect +import types +from typing import Any + +import simplecadapi + +_KINDS = { + inspect.Parameter.POSITIONAL_ONLY: "p", + inspect.Parameter.POSITIONAL_OR_KEYWORD: "", + inspect.Parameter.KEYWORD_ONLY: "", + inspect.Parameter.VAR_POSITIONAL: "a", + inspect.Parameter.VAR_KEYWORD: "w", +} +_DOC_LIMIT = 240 +_DEFAULT_LIMIT = 40 + + +def _summary(obj: Any) -> str: + """The first paragraph of the docstring, on one line.""" + + doc = inspect.getdoc(obj) or "" + first = doc.strip().split("\n\n", 1)[0] + text = " ".join(first.split()) + return text if len(text) <= _DOC_LIMIT else text[: _DOC_LIMIT - 1].rstrip() + "…" + + +def _parameters(obj: Any) -> list[dict[str, Any]] | None: + try: + signature = inspect.signature(obj) + except (TypeError, ValueError): + return None + rows: list[dict[str, Any]] = [] + for parameter in signature.parameters.values(): + if parameter.name == "self" or parameter.name.startswith("_"): + continue + required = parameter.default is inspect.Parameter.empty and parameter.kind in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ) + row: dict[str, Any] = {"n": parameter.name, "r": required, "k": _KINDS[parameter.kind]} + if parameter.default is not inspect.Parameter.empty and not str(parameter.default).startswith("<"): + text = repr(parameter.default) + row["d"] = text if len(text) <= _DEFAULT_LIMIT else text[: _DEFAULT_LIMIT - 1] + "…" + rows.append(row) + return rows + + +def _entry(name: str, obj: Any) -> dict[str, Any] | None: + if inspect.isclass(obj): + kind = "c" + elif inspect.isfunction(obj) or inspect.isbuiltin(obj): + kind = "f" + else: + return None + parameters = _parameters(obj) + if parameters is None: + return None + return {"n": name, "k": kind, "p": parameters, "d": _summary(obj)} + + +def _ours(obj: Any) -> bool: + """Defined by the SDK itself, not a typing helper or another library re-exported.""" + + module = getattr(obj, "__module__", "") or "" + return module == "simplecadapi" or module.startswith("simplecadapi.") + + +def _entries(module: types.ModuleType, names: list[str] | None = None, *, own: bool = False) -> list[dict[str, Any]]: + """Entries for the callables of *module*; with *own*, only those defined in that module (not re-exports).""" + + rows = [] + for name in sorted(names if names is not None else dir(module)): + if name.startswith("_"): + continue + obj = getattr(module, name, None) + if obj is None or isinstance(obj, types.ModuleType) or not _ours(obj): + continue + if own and not (getattr(obj, "__module__", "") or "").startswith(module.__name__): + continue + row = _entry(name, obj) + if row is not None: + rows.append(row) + return rows + + +@functools.lru_cache(maxsize=1) +def build_index() -> dict[str, Any]: + """``{"namespaces": {path: [entry]}, "submodules": {path: [name]}}``. + + An entry is ``{"n": name, "k": "f"|"c", "p": [parameter], "d": summary}``; a parameter is + ``{"n": name, "r": required, "k": "p"|"a"|"w"|"", "d"?: default}`` (``p`` positional only, + ``a`` the ``*args`` one, ``w`` the ``**kwargs`` one). + """ + + from simplecadapi import ql, std + + namespaces: dict[str, list[dict[str, Any]]] = { + "scad": _entries(simplecadapi, list(getattr(simplecadapi, "__all__", dir(simplecadapi)))), + "scad.ql": _entries(ql, own=True), + } + std_modules = sorted( + name for name in dir(std) if not name.startswith("_") and isinstance(getattr(std, name), types.ModuleType) + ) + for name in std_modules: + module = getattr(std, name) + namespaces[f"scad.std.{name}"] = _entries(module, list(getattr(module, "__all__", dir(module))), own=True) + submodules = {"scad": ["ql", "std"], "scad.std": std_modules} + return {"version": getattr(simplecadapi, "__version__", ""), "namespaces": namespaces, "submodules": submodules} diff --git a/src/simplecadapi/editor/server.py b/src/simplecadapi/editor/server.py index 35d48ce..7c5054f 100644 --- a/src/simplecadapi/editor/server.py +++ b/src/simplecadapi/editor/server.py @@ -1365,6 +1365,12 @@ async def latest_package(request: Request) -> Response: ) return JSONResponse({"error": "no package made yet"}, status_code=404) + async def sdk_index(request: Request) -> JSONResponse: + """The SDK's public API (names and parameters), for the cell editor's completion.""" + from .apiindex import build_index + + return JSONResponse(build_index(), headers={"Cache-Control": "max-age=3600"}) + async def workspace_list(request: Request) -> JSONResponse: """The SimpleCAD notebooks of the workspace.""" current = state.notebook.relative_to(state.workspace).as_posix() if state.notebook else None @@ -1484,6 +1490,7 @@ async def ws_endpoint(websocket) -> None: Route("/api/preview/latest", latest_preview, methods=["GET"]), Route("/api/runtime/package", push_package, methods=["POST"]), Route("/api/package/latest", latest_package, methods=["GET"]), + Route("/api/sdk", sdk_index, methods=["GET"]), Route("/api/workspace", workspace_list, methods=["GET"]), Route("/api/workspace/open", workspace_open, methods=["POST"]), Route("/api/workspace/new", workspace_new, methods=["POST"]), diff --git a/test/test_editor.py b/test/test_editor.py index f0021c0..d747b84 100644 --- a/test/test_editor.py +++ b/test/test_editor.py @@ -823,3 +823,61 @@ def test_a_running_cell_is_stopped_and_the_kernel_stays_usable(self) -> None: if kernel is not None: kernel.stop() server.should_exit = True + + +class ApiIndexTest(unittest.TestCase): + """The SDK's API as data, for the cell editor's completion.""" + + def setUp(self) -> None: + from simplecadapi.editor.apiindex import build_index + + self.index = build_index() + self.scad = {entry["n"]: entry for entry in self.index["namespaces"]["scad"]} + + def test_it_is_plain_json_and_small(self) -> None: + text = json.dumps(self.index, separators=(",", ":")) + self.assertLess(len(text), 400_000) + + def test_a_function_lists_its_parameters_and_which_are_required(self) -> None: + box = self.scad["make_box_rsolid"] + self.assertEqual(box["k"], "f") + required = [p["n"] for p in box["p"] if p["r"]] + self.assertEqual(required, ["width", "height", "depth"]) + optional = {p["n"]: p for p in box["p"] if not p["r"]} + self.assertIn("bottom_face_center", optional) + self.assertEqual(optional["bottom_face_center"]["d"], "(0, 0, 0)") + self.assertTrue(box["d"]) # the first line of its docstring + + def test_star_args_are_marked_and_not_required(self) -> None: + cut = self.scad["cut_rsolid"] + solids = next(p for p in cut["p"] if p["n"] == "solids") + self.assertEqual((solids["k"], solids["r"]), ("a", False)) + + def test_classes_list_their_constructor_without_private_fields(self) -> None: + part = self.scad["Part"] + self.assertEqual(part["k"], "c") + names = [p["n"] for p in part["p"]] + self.assertEqual([p["n"] for p in part["p"] if p["r"]], ["part_id", "body"]) + self.assertFalse([n for n in names if n.startswith("_")]) + + def test_namespaces_and_submodules(self) -> None: + namespaces = self.index["namespaces"] + self.assertIn("faces", {e["n"] for e in namespaces["scad.ql"]}) + self.assertIn("make_spur_gear_rsolid", {e["n"] for e in namespaces["scad.std.gear"]}) + self.assertEqual(self.index["submodules"]["scad"], ["ql", "std"]) + self.assertIn("gear", self.index["submodules"]["scad.std"]) + # nothing private, and the errors ql re-exports are not ql functions + for entries in namespaces.values(): + self.assertFalse([e["n"] for e in entries if e["n"].startswith("_")]) + self.assertNotIn("SimpleCADError", {e["n"] for e in namespaces["scad.ql"]}) + + def test_the_server_serves_it(self) -> None: + from starlette.testclient import TestClient + + from simplecadapi.editor.server import create_app + + with tempfile.TemporaryDirectory() as tmp: + app = create_app(WorkspaceState(Path(tmp))) + response = TestClient(app).get("/api/sdk") + self.assertEqual(response.status_code, 200) + self.assertIn("scad", response.json()["namespaces"])