From 2c8202a4139c983c1b32f80ad699f5a0bd928a8b Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 9 Feb 2026 17:10:03 -0700 Subject: [PATCH] Step 133: import statement generation --- PROGRESS.md | 3 +- editor/CMakeLists.txt | 4 + editor/src/EditorState.h | 32 +++ editor/src/ImportManager.h | 423 +++++++++++++++++++++++++++++++ editor/src/LibraryBrowserPanel.h | 3 + editor/src/main.cpp | 4 + editor/tests/step133_test.cpp | 43 ++++ sprint5_plan.md | 2 +- 8 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 editor/src/ImportManager.h create mode 100644 editor/tests/step133_test.cpp diff --git a/PROGRESS.md b/PROGRESS.md index f900234..ad562ab 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -382,7 +382,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi ## What's Next -Sprint 5 in progress. Step 132 (library symbol browser) done. Next: Step 133 (import statement generation). +Sprint 5 in progress. Step 133 (import statement generation) done. Next: Step 134 (available primitives registry). --- @@ -478,3 +478,4 @@ Sprint 5 in progress. Step 132 (library symbol browser) done. Next: Step 133 (im | 2026-02-09 | Codex | Step 130: LSP workspace symbol + completion indexing for dependencies with ExternalModule population and library index polling. 7/7 tests pass. | | 2026-02-09 | Codex | Step 131: Stub-based library indexing (pyi/d.ts/headers/lib.rs) with workspace scan fallback. 8/8 tests pass. | | 2026-02-09 | Codex | Step 132: Library symbol browser panel with filtering, doc detail display, and insert template helpers. 5/5 tests pass. | +| 2026-02-09 | Codex | Step 133: Import statement generation + unused import warnings across Python/JS/Rust/Go/Elisp with auto-insert on library symbol use. 7/7 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 1d2ea3d..ce59c28 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -746,6 +746,10 @@ target_link_libraries(step132_test PRIVATE imgui::imgui nlohmann_json::nlohmann_json) +add_executable(step133_test tests/step133_test.cpp) +target_include_directories(step133_test PRIVATE src) +target_link_libraries(step133_test PRIVATE nlohmann_json::nlohmann_json) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/EditorState.h b/editor/src/EditorState.h index a680ca7..9597b75 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -46,6 +46,7 @@ #include "DependencyPanel.h" #include "LibraryIndexer.h" #include "LibraryBrowserPanel.h" +#include "ImportManager.h" #include "IncrementalOptimizer.h" #include "ast/Serialization.h" #include "ast/Generator.h" @@ -367,6 +368,37 @@ struct EditorState { } } + void ensureImportForSymbol(const std::string& library, const std::string& symbol) { + if (!active() || library.empty()) return; + std::string clean = symbol; + auto paren = clean.find('('); + if (paren != std::string::npos) clean = clean.substr(0, paren); + ImportEditResult result = ensureImport(active()->editBuf, + active()->language, + library, + clean); + if (result.changed) { + active()->editBuf = result.text; + onTextChanged(); + } + } + + void appendUnusedImportDiagnostics(std::vector& diags) { + if (!active()) return; + auto issues = findUnusedImports(active()->editBuf, active()->language); + std::string uri = toFileUri(active()->path); + for (const auto& issue : issues) { + EditorDiagnostic ed; + ed.uri = uri; + ed.line = issue.line; + ed.character = 0; + ed.severity = 2; + ed.message = "[Imports] " + issue.message; + ed.source = "ImportManager"; + diags.push_back(std::move(ed)); + } + } + std::string makeUntitledName() const { if (!buffers.hasBuffer("(untitled)")) return "(untitled)"; int i = 1; diff --git a/editor/src/ImportManager.h b/editor/src/ImportManager.h new file mode 100644 index 0000000..4f5158f --- /dev/null +++ b/editor/src/ImportManager.h @@ -0,0 +1,423 @@ +#pragma once +#include +#include +#include +#include +#include + +struct ImportIssue { + int line = 0; + std::string symbol; + std::string message; +}; + +struct ImportEditResult { + std::string text; + bool changed = false; +}; + +static inline std::vector splitLines(const std::string& text) { + std::vector lines; + std::stringstream ss(text); + std::string line; + while (std::getline(ss, line)) { + lines.push_back(line); + } + if (!text.empty() && text.back() == '\n') lines.push_back(""); + return lines; +} + +static inline std::string joinLines(const std::vector& lines) { + std::string out; + for (size_t i = 0; i < lines.size(); ++i) { + out += lines[i]; + if (i + 1 < lines.size()) out += "\n"; + } + return out; +} + +static inline std::string trimStr(const std::string& s) { + size_t start = s.find_first_not_of(" \t\r\n"); + size_t end = s.find_last_not_of(" \t\r\n"); + if (start == std::string::npos || end == std::string::npos) return ""; + return s.substr(start, end - start + 1); +} + +static inline bool startsWith(const std::string& s, const std::string& prefix) { + return s.rfind(prefix, 0) == 0; +} + +static inline bool isIdentChar(char c) { + return std::isalnum((unsigned char)c) || c == '_' || c == '-'; +} + +static inline bool containsWord(const std::string& text, const std::string& word) { + if (word.empty()) return false; + size_t pos = 0; + while ((pos = text.find(word, pos)) != std::string::npos) { + bool leftOk = (pos == 0) || !isIdentChar(text[pos - 1]); + bool rightOk = (pos + word.size() >= text.size()) || + !isIdentChar(text[pos + word.size()]); + if (leftOk && rightOk) return true; + pos += word.size(); + } + return false; +} + +static inline std::string lastPathSegment(const std::string& path) { + auto pos = path.find_last_of("/."); + if (pos == std::string::npos) return path; + return path.substr(pos + 1); +} + +static inline ImportEditResult ensureImportPython(const std::string& text, + const std::string& library, + const std::string& symbol) { + ImportEditResult out{ text, false }; + std::string line; + if (startsWith(symbol, library + ".")) { + line = "import " + library; + } else { + line = "from " + library + " import " + symbol; + } + + auto lines = splitLines(text); + std::vector imports; + std::vector rest; + for (const auto& l : lines) { + std::string t = trimStr(l); + if (startsWith(t, "import ") || startsWith(t, "from ")) { + if (t != line) imports.push_back(l); + } else { + rest.push_back(l); + } + } + imports.push_back(line); + std::sort(imports.begin(), imports.end()); + out.text = joinLines(imports) + "\n" + joinLines(rest); + out.changed = true; + return out; +} + +static inline ImportEditResult ensureImportJs(const std::string& text, + const std::string& library, + const std::string& symbol) { + ImportEditResult out{ text, false }; + std::string name = symbol; + auto dot = name.find_last_of('.'); + if (dot != std::string::npos) name = name.substr(dot + 1); + std::string line = "import { " + name + " } from '" + library + "';"; + + auto lines = splitLines(text); + std::vector imports; + std::vector rest; + for (const auto& l : lines) { + std::string t = trimStr(l); + if (startsWith(t, "import ")) { + if (t != line) imports.push_back(l); + } else { + rest.push_back(l); + } + } + imports.push_back(line); + std::sort(imports.begin(), imports.end()); + out.text = joinLines(imports) + "\n" + joinLines(rest); + out.changed = true; + return out; +} + +static inline ImportEditResult ensureImportRust(const std::string& text, + const std::string& library, + const std::string& symbol) { + ImportEditResult out{ text, false }; + std::string line; + if (symbol.find("::") != std::string::npos) line = "use " + symbol + ";"; + else line = "use " + library + "::" + symbol + ";"; + + auto lines = splitLines(text); + std::vector imports; + std::vector rest; + for (const auto& l : lines) { + std::string t = trimStr(l); + if (startsWith(t, "use ")) { + if (t != line) imports.push_back(l); + } else { + rest.push_back(l); + } + } + imports.push_back(line); + std::sort(imports.begin(), imports.end()); + out.text = joinLines(imports) + "\n" + joinLines(rest); + out.changed = true; + return out; +} + +static inline ImportEditResult ensureImportGo(const std::string& text, + const std::string& library) { + ImportEditResult out{ text, false }; + std::string path = "\"" + library + "\""; + std::string line = "import " + path; + auto lines = splitLines(text); + int pkgLine = -1; + int importStart = -1; + int importEnd = -1; + for (int i = 0; i < (int)lines.size(); ++i) { + std::string t = trimStr(lines[i]); + if (startsWith(t, "package ")) pkgLine = i; + if (startsWith(t, "import (")) { importStart = i; } + if (importStart >= 0 && t == ")") { importEnd = i; break; } + if (importStart < 0 && startsWith(t, "import \"")) { + importStart = i; + importEnd = i; + break; + } + } + + if (importStart >= 0 && importEnd >= importStart && importStart != importEnd) { + std::vector imports; + for (int i = importStart + 1; i < importEnd; ++i) { + std::string t = trimStr(lines[i]); + if (!t.empty()) imports.push_back(t); + } + if (std::find(imports.begin(), imports.end(), path) == imports.end()) { + imports.push_back(path); + } + std::sort(imports.begin(), imports.end()); + std::vector outLines; + for (int i = 0; i <= importStart; ++i) outLines.push_back(lines[i]); + for (const auto& imp : imports) outLines.push_back(" " + imp); + for (int i = importEnd; i < (int)lines.size(); ++i) outLines.push_back(lines[i]); + out.text = joinLines(outLines); + out.changed = true; + return out; + } + + if (importStart >= 0 && importStart == importEnd) { + std::string existing = trimStr(lines[importStart]); + std::vector imports = { existing }; + if (existing != line) imports.push_back(line); + std::sort(imports.begin(), imports.end()); + std::vector outLines; + for (int i = 0; i < importStart; ++i) outLines.push_back(lines[i]); + outLines.push_back("import ("); + for (const auto& imp : imports) { + std::string t = trimStr(imp); + if (startsWith(t, "import ")) t = trimStr(t.substr(6)); + outLines.push_back(" " + t); + } + outLines.push_back(")"); + for (int i = importStart + 1; i < (int)lines.size(); ++i) outLines.push_back(lines[i]); + out.text = joinLines(outLines); + out.changed = true; + return out; + } + + int insertAt = (pkgLine >= 0) ? pkgLine + 1 : 0; + std::vector outLines; + for (int i = 0; i < insertAt; ++i) outLines.push_back(lines[i]); + outLines.push_back("import ("); + outLines.push_back(" " + path); + outLines.push_back(")"); + for (int i = insertAt; i < (int)lines.size(); ++i) outLines.push_back(lines[i]); + out.text = joinLines(outLines); + out.changed = true; + return out; +} + +static inline ImportEditResult ensureImportElisp(const std::string& text, + const std::string& library) { + ImportEditResult out{ text, false }; + std::string line = "(require '" + library + ")"; + auto lines = splitLines(text); + for (const auto& l : lines) { + if (trimStr(l) == line) return out; + } + std::vector outLines; + outLines.push_back(line); + outLines.insert(outLines.end(), lines.begin(), lines.end()); + out.text = joinLines(outLines); + out.changed = true; + return out; +} + +static inline ImportEditResult ensureImportCpp(const std::string& text, + const std::string& library) { + ImportEditResult out{ text, false }; + std::string line = "#include <" + library + ">"; + auto lines = splitLines(text); + std::vector imports; + std::vector rest; + for (const auto& l : lines) { + std::string t = trimStr(l); + if (startsWith(t, "#include")) { + if (t != line) imports.push_back(l); + } else { + rest.push_back(l); + } + } + imports.push_back(line); + std::sort(imports.begin(), imports.end()); + out.text = joinLines(imports) + "\n" + joinLines(rest); + out.changed = true; + return out; +} + +static inline ImportEditResult ensureImport(const std::string& text, + const std::string& language, + const std::string& library, + const std::string& symbol) { + if (library.empty()) return {text, false}; + ImportEditResult res; + if (language == "python") res = ensureImportPython(text, library, symbol); + else if (language == "javascript" || language == "typescript") + res = ensureImportJs(text, library, symbol); + else if (language == "rust") res = ensureImportRust(text, library, symbol); + else if (language == "go") res = ensureImportGo(text, library); + else if (language == "elisp") res = ensureImportElisp(text, library); + else res = ensureImportCpp(text, library); + if (res.text == text) res.changed = false; + return res; +} + +static inline std::vector findUnusedImportsPython(const std::string& text) { + std::vector issues; + auto lines = splitLines(text); + std::string body; + for (const auto& l : lines) { + std::string t = trimStr(l); + if (startsWith(t, "import ") || startsWith(t, "from ")) continue; + body += l; + body += "\n"; + } + for (int i = 0; i < (int)lines.size(); ++i) { + std::string t = trimStr(lines[i]); + std::string symbol; + if (startsWith(t, "import ")) { + symbol = trimStr(t.substr(7)); + } else if (startsWith(t, "from ")) { + auto pos = t.find("import"); + if (pos != std::string::npos) symbol = trimStr(t.substr(pos + 6)); + } + if (!symbol.empty() && symbol != "*") { + if (!containsWord(body, symbol)) { + issues.push_back({i, symbol, "Unused import: " + symbol}); + } + } + } + return issues; +} + +static inline std::vector findUnusedImportsJs(const std::string& text) { + std::vector issues; + auto lines = splitLines(text); + std::string body; + for (const auto& l : lines) { + std::string t = trimStr(l); + if (startsWith(t, "import ")) continue; + body += l; + body += "\n"; + } + for (int i = 0; i < (int)lines.size(); ++i) { + std::string t = trimStr(lines[i]); + if (!startsWith(t, "import ")) continue; + auto brace = t.find('{'); + auto from = t.find(" from "); + if (brace != std::string::npos && from != std::string::npos) { + auto end = t.find('}', brace); + if (end == std::string::npos) continue; + std::string names = t.substr(brace + 1, end - brace - 1); + std::stringstream ss(names); + std::string item; + while (std::getline(ss, item, ',')) { + item = trimStr(item); + if (!item.empty() && !containsWord(body, item)) { + issues.push_back({i, item, "Unused import: " + item}); + } + } + } + } + return issues; +} + +static inline std::vector findUnusedImportsRust(const std::string& text) { + std::vector issues; + auto lines = splitLines(text); + std::string body; + for (const auto& l : lines) { + std::string t = trimStr(l); + if (startsWith(t, "use ")) continue; + body += l; + body += "\n"; + } + for (int i = 0; i < (int)lines.size(); ++i) { + std::string t = trimStr(lines[i]); + if (!startsWith(t, "use ")) continue; + auto pos = t.find("::"); + if (pos == std::string::npos) continue; + std::string symbol = t.substr(t.find_last_of(':') + 1); + if (!symbol.empty() && symbol.back() == ';') symbol.pop_back(); + if (!symbol.empty() && !containsWord(body, symbol)) { + issues.push_back({i, symbol, "Unused import: " + symbol}); + } + } + return issues; +} + +static inline std::vector findUnusedImportsGo(const std::string& text) { + std::vector issues; + auto lines = splitLines(text); + std::string body; + for (const auto& l : lines) { + std::string t = trimStr(l); + if (startsWith(t, "import ")) continue; + body += l; + body += "\n"; + } + for (int i = 0; i < (int)lines.size(); ++i) { + std::string t = trimStr(lines[i]); + if (startsWith(t, "import \"")) { + std::string path = t.substr(8); + if (!path.empty() && path.back() == '"') path.pop_back(); + std::string symbol = lastPathSegment(path); + if (!symbol.empty() && body.find(symbol + ".") == std::string::npos) { + issues.push_back({i, symbol, "Unused import: " + symbol}); + } + } + } + return issues; +} + +static inline std::vector findUnusedImportsElisp(const std::string& text) { + std::vector issues; + auto lines = splitLines(text); + std::string body; + for (const auto& l : lines) { + std::string t = trimStr(l); + if (startsWith(t, "(require '")) continue; + body += l; + body += "\n"; + } + for (int i = 0; i < (int)lines.size(); ++i) { + std::string t = trimStr(lines[i]); + if (startsWith(t, "(require '")) { + auto start = t.find('\''); + auto end = t.find(')', start); + if (start == std::string::npos || end == std::string::npos) continue; + std::string symbol = t.substr(start + 1, end - start - 1); + if (!symbol.empty() && body.find(symbol) == std::string::npos) { + issues.push_back({i, symbol, "Unused import: " + symbol}); + } + } + } + return issues; +} + +static inline std::vector findUnusedImports(const std::string& text, + const std::string& language) { + if (language == "python") return findUnusedImportsPython(text); + if (language == "javascript" || language == "typescript") return findUnusedImportsJs(text); + if (language == "rust") return findUnusedImportsRust(text); + if (language == "go") return findUnusedImportsGo(text); + if (language == "elisp") return findUnusedImportsElisp(text); + return {}; +} diff --git a/editor/src/LibraryBrowserPanel.h b/editor/src/LibraryBrowserPanel.h index 79d2d89..2706f18 100644 --- a/editor/src/LibraryBrowserPanel.h +++ b/editor/src/LibraryBrowserPanel.h @@ -57,8 +57,10 @@ static bool renderLibraryBrowser(LibraryBrowserState& state, Module* ast, const LibraryIndexData& index, std::string& outInsert, + std::string& outLibrary, std::string& outputLog) { outInsert.clear(); + outLibrary.clear(); if (!ast) { ImGui::TextDisabled("(no structured AST)"); return false; @@ -109,6 +111,7 @@ static bool renderLibraryBrowser(LibraryBrowserState& state, std::string usage = formatUsageTemplate(state.selectedSymbol); if (ImGui::Button("Insert")) { outInsert = usage; + outLibrary = state.selectedLibrary; outputLog += "[libs] Inserted " + usage + "\n"; inserted = true; } diff --git a/editor/src/main.cpp b/editor/src/main.cpp index 4a38302..0fb2fc3 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -573,11 +573,14 @@ int main(int, char**) { ImGui::Begin("Libraries", &state.showLibraryBrowserPanel); ImGui::PushFont(uiFont); std::string insertText; + std::string insertLibrary; if (renderLibraryBrowser(state.libraryBrowser, state.activeAST(), state.libraryIndex, insertText, + insertLibrary, state.outputLog)) { + state.ensureImportForSymbol(insertLibrary, insertText); state.insertTextAtCursor(insertText); } ImGui::PopFont(); @@ -1290,6 +1293,7 @@ int main(int, char**) { collectWhetstoneDiagnostics(result.validationDiags, result.violations, EditorState::toFileUri(state.active()->path)); + state.appendUnusedImportDiagnostics(state.whetstoneDiagnostics); } else { state.whetstoneDiagnostics.clear(); } diff --git a/editor/tests/step133_test.cpp b/editor/tests/step133_test.cpp new file mode 100644 index 0000000..719a81d --- /dev/null +++ b/editor/tests/step133_test.cpp @@ -0,0 +1,43 @@ +// Step 133 TDD Test: Import statement generation + unused import detection +#include "ImportManager.h" +#include + +static void expect(bool cond, const std::string& name, int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) << " FAIL: " << name << "\n"; + ++failed; + } +} + +int main() { + int passed = 0; + int failed = 0; + + std::string py = "import os\n\nprint('hi')\n"; + auto pyRes = ensureImport(py, "python", "numpy", "array"); + expect(pyRes.changed, "python import changed", passed, failed); + expect(pyRes.text.find("from numpy import array") != std::string::npos, + "python import inserted", passed, failed); + + std::string js = "import { useEffect } from 'react';\n\nconsole.log('x');\n"; + auto jsRes = ensureImport(js, "javascript", "react", "useState"); + expect(jsRes.changed, "js import changed", passed, failed); + expect(jsRes.text.find("import { useState } from 'react';") != std::string::npos, + "js import inserted", passed, failed); + + std::string go = "package main\n\nfunc main() {}\n"; + auto goRes = ensureImport(go, "go", "fmt", "fmt"); + expect(goRes.changed, "go import changed", passed, failed); + expect(goRes.text.find("import (\n \"fmt\"\n)") != std::string::npos, + "go import block formatting", passed, failed); + + std::string pyUnused = "import os\nprint('hi')\n"; + auto issues = findUnusedImports(pyUnused, "python"); + expect(!issues.empty(), "unused import detected", passed, failed); + + std::cout << "\n=== Step 133 Results: " << passed << " passed, " << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint5_plan.md b/sprint5_plan.md index a8dd054..df36b33 100644 --- a/sprint5_plan.md +++ b/sprint5_plan.md @@ -78,7 +78,7 @@ The foundation: import libraries, parse their APIs, index their symbols. Drag a symbol into the editor → inserts a usage template. *New:* `LibraryBrowser` panel -- [ ] **Step 133: Import statement generation** +- [x] **Step 133: Import statement generation** When user references a symbol from an `ExternalModule`, auto-generate the correct import statement for the target language: - Python: `from numpy import array`