From d4c1f0e86b688b65a764ad251bec50663fde75fe Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:58:59 +0300 Subject: [PATCH] Open project C++ and Lua sources at diagnostic locations --- src/editor/session.cpp | 137 ++++++++++++++++++++++----------- tests/editor_session_tests.cpp | 37 ++++++++- 2 files changed, 130 insertions(+), 44 deletions(-) diff --git a/src/editor/session.cpp b/src/editor/session.cpp index b246b24..1da1eaa 100644 --- a/src/editor/session.cpp +++ b/src/editor/session.cpp @@ -502,51 +502,102 @@ void Session::register_commands() { {"configuration_created", create_configuration}, {"scripts_configuration_created", create_scripts_configuration}}; }); - commands_.add( - "faset_script_open", - "Open a project Lua source in an external editor, never as an executable. Optional editor " - "is an argv array (default: project editor.script_editor, then zed). Exact {file} and " - "{project} arguments are replaced; no shell expansion is performed.", - schema({{"path", text}, {"editor", {{"type", "array"}, {"items", text}, {"minItems", 1}}}}, - {"path"}), - [&](const Json& args) { - const auto file = project_path(config_.project_root, - path_from_utf8(args.at("path").get())); - require(file.extension() == ".lua" && std::filesystem::is_regular_file(file), - "lua.source", "Select an existing .lua file inside the project"); - Json command = Json::array({"zed", "{file}"}); - const auto settings = project(); - if (settings.contains("editor") && settings.at("editor").is_object() && - settings.at("editor").contains("script_editor")) - command = settings.at("editor").at("script_editor"); - if (args.contains("editor")) - command = args.at("editor"); - require(command.is_array() && !command.empty(), "lua.editor", - "Configure editor.script_editor as a nonempty executable/argument array"); - std::vector arguments; - bool has_file = false; - for (const auto& part : command) { - require(part.is_string(), "lua.editor", "Editor arguments must be strings"); - auto argument = part.get(); - require(argument.find('\0') == std::string::npos, "lua.editor", - "Editor arguments cannot contain NUL"); - if (argument == "{file}") { - require(!arguments.empty(), "lua.editor", "The first argument is the editor"); - argument = path_to_utf8(file); - has_file = true; - } else if (argument == "{project}") { - require(!arguments.empty(), "lua.editor", "The first argument is the editor"); - argument = path_to_utf8(std::filesystem::absolute(config_.project_root)); - } - arguments.push_back(std::move(argument)); + const auto open_source = [this](const Json& args, bool lua_only) -> Json { + const auto path_error = lua_only ? "lua.source" : "source.path"; + const auto editor_error = lua_only ? "lua.editor" : "source.editor"; + const auto requested = path_from_utf8(args.at("path").get()); + require(!requested.empty() && !requested.is_absolute(), path_error, + "Choose an existing source inside Scripts"); + for (const auto& part : requested) + require(part != "..", path_error, "Source path cannot traverse outside Scripts"); + std::filesystem::path file; + try { + file = project_path(config_.project_root, requested); + } catch (const std::exception&) { + throw Error(path_error, "Source path must stay inside project Scripts"); + } + const auto project_root = std::filesystem::weakly_canonical(config_.project_root); + const auto relative = file.lexically_relative(project_root); + const auto name = generic_path_to_utf8(relative); + const auto extension = path_to_utf8(file.extension()); + const bool supported = extension == ".cpp" || extension == ".hpp" || + extension == ".h" || extension == ".cc" || + extension == ".cxx" || extension == ".lua"; + require(name.starts_with("Scripts/") && supported && + (!lua_only || extension == ".lua") && + std::filesystem::is_regular_file(file), + path_error, "Choose an existing source file inside Scripts"); + const auto line = args.value("line", 1); + const auto column = args.value("column", 1); + require(line > 0 && column > 0, path_error, "Source line and column must be positive"); + Json command = Json::array({"zed", "{file}:{line}:{column}"}); + const auto settings = project(); + if (settings.contains("editor") && settings.at("editor").is_object() && + settings.at("editor").contains("script_editor")) + command = settings.at("editor").at("script_editor"); + if (args.contains("editor")) + command = args.at("editor"); + require(command.is_array() && !command.empty(), editor_error, + "Configure editor.script_editor as a nonempty executable/argument array"); + auto replace = [](std::string& target, std::string_view token, + const std::string& replacement) { + bool found = false; + std::size_t position = 0; + while ((position = target.find(token, position)) != std::string::npos) { + target.replace(position, token.size(), replacement); + position += replacement.size(); + found = true; } - require(!arguments.front().empty(), "lua.editor", "Choose an editor executable"); - if (!has_file) - arguments.push_back(path_to_utf8(file)); + return found; + }; + std::vector arguments; + bool has_file = false; + for (const auto& part : command) { + require(part.is_string(), editor_error, "Editor arguments must be strings"); + auto argument = part.get(); + require(argument.find('\0') == std::string::npos, editor_error, + "Editor arguments cannot contain NUL"); + if (arguments.empty()) + require(!argument.empty() && argument.find('{') == std::string::npos, + editor_error, "The first argument must be an editor executable"); + else { + has_file |= replace(argument, "{file}", path_to_utf8(file)); + replace(argument, "{line}", std::to_string(line)); + replace(argument, "{column}", std::to_string(column)); + replace(argument, "{project}", path_to_utf8(project_root)); + } + arguments.push_back(std::move(argument)); + } + if (!has_file) + arguments.push_back(path_to_utf8(file)); + try { launch_detached(arguments, config_.project_root); - log("Opened Lua source in external editor: " + args.at("path").get()); - return Json{{"opened", args.at("path")}}; - }); + } catch (const std::exception& error) { + throw Error(editor_error, "Could not launch external source editor", + {{"reason", error.what()}}); + } + log("Opened source in external editor: " + name); + return {{"opened", name}, {"path", name}, {"line", line}, {"column", column}}; + }; + const auto source_schema = schema( + {{"path", text}, + {"line", {{"type", "integer"}, {"minimum", 1}}}, + {"column", {{"type", "integer"}, {"minimum", 1}}}, + {"editor", {{"type", "array"}, {"items", text}, {"minItems", 1}}}}, + {"path"}); + commands_.add("faset_source_open", + "Open a C++ or Lua source under Scripts at a one-based line and column. " + "The editor is an argv array; {file}, {line}, {column} and {project} are " + "substituted without a shell.", + source_schema, [open_source](const Json& args) { + return open_source(args, false); + }); + commands_.add("faset_script_open", + "Open a project Lua source in an external editor. This compatibility command " + "retains Lua-only validation and uses the same safe source launcher.", + source_schema, [open_source](const Json& args) { + return open_source(args, true); + }); commands_.add("faset_export", "Build, validate and export a resolved authoring snapshot to a project-relative " "output directory. Returns a job ID.", diff --git a/tests/editor_session_tests.cpp b/tests/editor_session_tests.cpp index 666044f..071b817 100644 --- a/tests/editor_session_tests.cpp +++ b/tests/editor_session_tests.cpp @@ -1,9 +1,18 @@ #include #include +#include #include +#include -int main() { +int main(int argc, char** argv) { using namespace faset; + if (argc >= 3 && std::string_view(argv[1]) == "--editor-probe") { + Json arguments = Json::array(); + for (int index = 3; index < argc; ++index) + arguments.push_back(argv[index]); + atomic_write_json(path_from_utf8(argv[2]), arguments); + return 0; + } const auto root = std::filesystem::temp_directory_path() / path_from_utf8("Faset Café 世界 session " + new_id()); try { @@ -115,6 +124,32 @@ int main() { {{"path", "Scripts/behavior.lua"}, {"editor", {"{file}"}}}, "lua.editor"); reject_command("faset_script_open", {{"path", "Scripts/behavior.lua"}, {"editor", {""}}}, "lua.editor"); + const auto probe = root / "editor-argv.json"; + const auto opened = commands.call( + "faset_source_open", + {{"path", "Scripts/Gameplay.cpp"}, {"line", 17}, {"column", 4}, + {"editor", Json::array({path_to_utf8(std::filesystem::absolute(path_from_utf8(argv[0]))), + "--editor-probe", path_to_utf8(probe), + "{file}:{line}:{column}", "{project}"})}}); + for (int attempt = 0; attempt < 100 && !std::filesystem::exists(probe); ++attempt) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + require(opened.at("path") == "Scripts/Gameplay.cpp" && opened.at("line") == 17 && + std::filesystem::is_regular_file(probe) && + read_json(probe) == + Json::array({path_to_utf8(root / "Scripts/Gameplay.cpp") + ":17:4", + path_to_utf8(root)}), + "test", "Source navigation passes a Unicode path and location as literal argv"); + reject_command("faset_source_open", {{"path", "../outside.cpp"}}, "source.path"); + reject_command("faset_source_open", {{"path", "Scripts/missing.cpp"}}, "source.path"); + reject_command("faset_source_open", {{"path", "Scripts"}}, "source.path"); + atomic_write(root / "Scripts/tool.exe", "not a source\n"); + reject_command("faset_source_open", {{"path", "Scripts/tool.exe"}}, "source.path"); + std::filesystem::create_directories(root / "Outside"); + std::error_code link_error; + std::filesystem::create_symlink(root / "Outside/secret.cpp", + root / "Scripts/escape.cpp", link_error); + if (!link_error) + reject_command("faset_source_open", {{"path", "Scripts/escape.cpp"}}, "source.path"); auto bad_lua_project = session.project(); bad_lua_project["scripting"] = {{"lua", {{"scripts", {"Scripts/missing.lua"}}}}}; atomic_write_json(root / "project.faset.json", bad_lua_project);