1 /*
2 * SessionQuarto.cpp
3 *
4 * Copyright (C) 2021 by RStudio, PBC
5 *
6 * Unless you have received this program directly from RStudio pursuant
7 * to the terms of a commercial license agreement with RStudio, then
8 * this program is licensed to you under the terms of version 3 of the
9 * GNU Affero General Public License. This program is distributed WITHOUT
10 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13 *
14 */
15
16 #include "SessionQuarto.hpp"
17
18 #include <string>
19
20 #include <yaml-cpp/yaml.h>
21
22 #include <shared_core/Error.hpp>
23 #include <shared_core/FilePath.hpp>
24 #include <shared_core/json/Json.hpp>
25
26 #include <r/RExec.hpp>
27 #include <r/RRoutines.hpp>
28
29 #include <core/Exec.hpp>
30 #include <core/Version.hpp>
31 #include <core/YamlUtil.hpp>
32 #include <core/StringUtils.hpp>
33 #include <core/FileSerializer.hpp>
34 #include <core/text/AnsiCodeParser.hpp>
35
36 #include <core/json/JsonRpc.hpp>
37
38 #include <core/system/Process.hpp>
39
40 #include <r/RExec.hpp>
41
42 #include <session/SessionModuleContext.hpp>
43 #include <session/SessionSourceDatabase.hpp>
44 #include <session/SessionConsoleProcess.hpp>
45 #include <session/SessionQuarto.hpp>
46 #include <session/projects/SessionProjects.hpp>
47
48 #include <session/prefs/UserPrefs.hpp>
49 #include <session/SessionQuarto.hpp>
50
51 #include "SessionQuartoPreview.hpp"
52 #include "SessionQuartoServe.hpp"
53 #include "SessionQuartoXRefs.hpp"
54
55 using namespace rstudio::core;
56
57 namespace rstudio {
58 namespace session {
59
60 using namespace quarto;
61
62 namespace {
63
64 const char * const kQuartoXt = "quarto-document";
65
66 FilePath s_quartoPath;
67 std::string s_quartoVersion;
68
detectQuartoInstallation()69 void detectQuartoInstallation()
70 {
71 // reset
72 s_quartoPath = FilePath();
73 s_quartoVersion = "";
74
75 // see if quarto is on the path
76 FilePath quartoPath = module_context::findProgram("quarto");
77 if (!quartoPath.isEmpty())
78 {
79 // convert to real path
80 Error error = core::system::realPath(quartoPath, &quartoPath);
81 if (!error)
82 {
83 // read version file -- if it doesn't exist we are running the dev
84 // version so are free to proceed
85 FilePath versionFile = quartoPath
86 .getParent()
87 .getParent()
88 .completeChildPath("share")
89 .completeChildPath("version");
90 std::string contents;
91 if (versionFile.exists())
92 {
93 error = core::readStringFromFile(versionFile, &contents);
94 if (error)
95 {
96 LOG_ERROR(error);
97 return;
98 }
99 }
100 else
101 {
102 // dev version
103 contents = "99.9.9";
104 }
105
106 const Version kQuartoRequiredVersion("0.2.83");
107 boost::algorithm::trim(contents);
108 Version quartoVersion(contents);
109 if (quartoVersion >= kQuartoRequiredVersion)
110 {
111 s_quartoPath = quartoPath;
112 s_quartoVersion = quartoVersion;
113 }
114 else
115 {
116 // enque a warning
117 const char * const kUpdateURL = "https://github.com/quarto-dev/quarto-cli/releases/latest";
118 json::Object msgJson;
119 msgJson["severe"] = false;
120 boost::format fmt(
121 "Quarto CLI version %1% is installed, however RStudio requires version %2%. "
122 "Please update to the latest version at <a href=\"%3%\" target=\"_blank\">%3%</a>"
123 );
124 msgJson["message"] = boost::str(fmt %
125 std::string(quartoVersion) %
126 std::string(kQuartoRequiredVersion) %
127 kUpdateURL);
128 ClientEvent event(client_events::kShowWarningBar, msgJson);
129 module_context::enqueClientEvent(event);
130 }
131 }
132 }
133 }
134
quartoIsInstalled()135 bool quartoIsInstalled()
136 {
137 return !s_quartoPath.isEmpty();
138 }
139
140
quartoConfigFilePath(const FilePath & dirPath)141 core::FilePath quartoConfigFilePath(const FilePath& dirPath)
142 {
143 FilePath quartoYml = dirPath.completePath("_quarto.yml");
144 if (quartoYml.exists())
145 return quartoYml;
146
147 FilePath quartoYaml = dirPath.completePath("_quarto.yaml");
148 if (quartoYaml.exists())
149 return quartoYaml;
150
151 return FilePath();
152 }
153
quartoProjectConfigFile(const core::FilePath & filePath)154 core::FilePath quartoProjectConfigFile(const core::FilePath& filePath)
155 {
156 // list all paths up to root from home dir
157 // if we hit the anchor path, or any parent directory of that path
158 FilePath anchorPath = module_context::userHomePath();
159 std::vector<FilePath> anchorPaths;
160 for (; anchorPath.exists(); anchorPath = anchorPath.getParent())
161 anchorPaths.push_back(anchorPath);
162
163 // scan through parents
164 for (FilePath targetPath = filePath.getParent(); targetPath.exists(); targetPath = targetPath.getParent())
165 {
166 // bail if we've hit our anchor
167 for (const FilePath& anchorPath : anchorPaths)
168 {
169 if (targetPath == anchorPath)
170 return FilePath();
171 }
172
173 // see if we have a config
174 FilePath configFile = quartoConfigFilePath(targetPath);
175 if (!configFile.isEmpty())
176 return configFile;
177 }
178
179 return FilePath();
180 }
181
182
onDetectQuartoSourceType(boost::shared_ptr<source_database::SourceDocument> pDoc)183 std::string onDetectQuartoSourceType(
184 boost::shared_ptr<source_database::SourceDocument> pDoc)
185 {
186 // short circuit everything if this is already marked as a quarto markdown doc
187 if (pDoc->type() == "quarto_markdown")
188 {
189 return kQuartoXt;
190 }
191
192 if (!pDoc->path().empty())
193 {
194 FilePath filePath = module_context::resolveAliasedPath(pDoc->path());
195 if (filePath.getExtensionLowerCase() == ".qmd")
196 {
197 return kQuartoXt;
198 }
199 else if (filePath.getExtensionLowerCase() == ".rmd" ||
200 filePath.getExtensionLowerCase() == ".md")
201 {
202 // if we have a format: or knit: quarto render then it's a quarto document
203 std::string yamlHeader = yaml::extractYamlHeader(pDoc->contents());
204 static const boost::regex reOutput("(^|\\n)output:\\s*");
205 static const boost::regex reFormat("(^|\\n)format:\\s*");
206 static const boost::regex reJupyter("(^|\\n)jupyter:\\s*");
207 static const boost::regex reKnitQuarto("(^|\\n)knit:\\s*quarto\\s+render");
208 // format: without output:
209 if (regex_utils::search(yamlHeader.begin(), yamlHeader.end(), reFormat) &&
210 !regex_utils::search(yamlHeader.begin(), yamlHeader.end(), reOutput))
211 {
212 return kQuartoXt;
213 }
214 // knit: quarto render
215 else if (regex_utils::search(yamlHeader.begin(), yamlHeader.end(), reKnitQuarto))
216 {
217 return kQuartoXt;
218 }
219 // project has quarto config in build target dir
220 else if (filePath.isWithin(projects::projectContext().directory()) && projectIsQuarto())
221 {
222 return kQuartoXt;
223
224 // file has a parent directory with a quarto config
225 } else if (quartoIsInstalled() && !quartoProjectConfigFile(filePath).isEmpty()) {
226 return kQuartoXt;
227 }
228 }
229 }
230
231 // quarto type not detected
232 return "";
233 }
234
235
quartoOptions()236 core::system::ProcessOptions quartoOptions()
237 {
238 core::system::ProcessOptions options;
239 #ifdef _WIN32
240 options.createNewConsole = true;
241 #else
242 options.terminateChildren = true;
243 #endif
244 return options;
245 }
246
runQuarto(const std::vector<std::string> & args,const core::FilePath & workingDir,core::system::ProcessResult * pResult)247 Error runQuarto(const std::vector<std::string>& args,
248 const core::FilePath& workingDir,
249 core::system::ProcessResult* pResult)
250 {
251 core::system::ProcessOptions options = quartoOptions();
252 if (!workingDir.isEmpty())
253 options.workingDir = workingDir;
254
255 return core::system::runProgram(
256 string_utils::utf8ToSystem(s_quartoPath.getAbsolutePath()),
257 args,
258 "",
259 options,
260 pResult
261 );
262 }
263
264
quartoExec(const std::vector<std::string> & args,const core::FilePath & workingDir,core::system::ProcessResult * pResult)265 Error quartoExec(const std::vector<std::string>& args,
266 const core::FilePath& workingDir,
267 core::system::ProcessResult* pResult)
268 {
269 // run pandoc
270 Error error = runQuarto(args, workingDir, pResult);
271 if (error)
272 {
273 return error;
274 }
275 else if (pResult->exitStatus != EXIT_SUCCESS)
276 {
277 Error error = systemError(boost::system::errc::state_not_recoverable, pResult->stdErr, ERROR_LOCATION);
278 return error;
279 }
280 else
281 {
282 return Success();
283 }
284 }
285
quartoExec(const std::vector<std::string> & args,core::system::ProcessResult * pResult)286 Error quartoExec(const std::vector<std::string>& args,
287 core::system::ProcessResult* pResult)
288 {
289 return quartoExec(args, FilePath(), pResult);
290 }
291
quartoExec(const std::vector<std::string> & args,core::system::ProcessResult * pResult,json::JsonRpcResponse * pResponse)292 bool quartoExec(const std::vector<std::string>& args,
293 core::system::ProcessResult* pResult,
294 json::JsonRpcResponse* pResponse)
295 {
296 // run pandoc
297 Error error = runQuarto(args, FilePath(), pResult);
298 if (error)
299 {
300 json::setErrorResponse(error, pResponse);
301 return false;
302 }
303 else if (pResult->exitStatus != EXIT_SUCCESS)
304 {
305 json::setProcessErrorResponse(*pResult, ERROR_LOCATION, pResponse);
306 return false;
307 }
308 else
309 {
310 return true;
311 }
312 }
313
quartoCapabilitiesRpc(const json::JsonRpcRequest &,json::JsonRpcResponse * pResponse)314 Error quartoCapabilitiesRpc(const json::JsonRpcRequest&,
315 json::JsonRpcResponse* pResponse)
316 {
317 core::system::ProcessResult result;
318 if (quartoExec({"capabilities"}, &result, pResponse))
319 {
320 json::Object jsonCapabilities;
321 if (json::parseJsonForResponse(result.stdOut, &jsonCapabilities, pResponse))
322 pResponse->setResult(jsonCapabilities);
323 }
324
325 return Success();
326 }
327
328
getQmdPublishDetails(const json::JsonRpcRequest & request,json::JsonRpcResponse * pResponse)329 Error getQmdPublishDetails(const json::JsonRpcRequest& request,
330 json::JsonRpcResponse* pResponse)
331 {
332 using namespace module_context;
333
334 std::string target;
335 Error error = json::readParams(request.params, &target);
336 if (error)
337 {
338 return error;
339 }
340
341 FilePath qmdPath = module_context::resolveAliasedPath(target);
342
343 // Ask Quarto to get the metadata for the file
344 json::Object inspect;
345 error = quartoInspect(qmdPath.getAbsolutePath(), &inspect);
346 if (error)
347 {
348 return error;
349 }
350
351 auto formats = inspect["formats"].getObject();
352 auto format = (*formats.begin()).getValue().getObject();
353
354 json::Object result;
355
356 auto formatMeta = format.find("metadata");
357
358 // Establish output vars
359 std::string title = qmdPath.getStem();
360 bool isShinyQmd = false;
361 bool selfContained = false;
362 std::string outputFile;
363
364 // If we were able to get the format's metadata, read it
365 if (formatMeta != format.end())
366 {
367 json::Object formatMetadata = (*formatMeta).getValue().getObject();
368
369 // Attempt to read file title; use stem as a fallback
370 json::readObject(formatMetadata, "title", title);
371
372 // Attempt to read file title; use stem as a fallback
373 std::string runtime;
374 error = json::readObject(formatMetadata, "runtime", runtime);
375 if (!error)
376 {
377 isShinyQmd = runtime == "shinyrmd" || runtime == "shiny_prerendered";
378 }
379 if (!isShinyQmd)
380 {
381 std::string server;
382 json::readObject(formatMetadata, "server", server);
383 isShinyQmd = server == "shiny";
384 if (!isShinyQmd)
385 {
386 json::Object serverJson;
387 error = json::readObject(formatMetadata, "server", serverJson);
388 if (!error)
389 {
390 std::string type;
391 error = json::readObject(serverJson, "type", type);
392 isShinyQmd = type == "shiny";
393 }
394 }
395 }
396 }
397
398
399 // If we were able to get the format's pandoc parameters, read them as well
400 auto pandoc = format.find("pandoc");
401 if (pandoc != format.end())
402 {
403 json::Object pandocMetadata = (*pandoc).getValue().getObject();
404 json::readObject(pandocMetadata, "self-contained", selfContained);
405
406 std::string pandocOutput;
407 json::readObject(pandocMetadata, "output-file", pandocOutput);
408 auto outputFilePath = qmdPath.getParent().completeChildPath(pandocOutput);
409 if (outputFilePath.exists())
410 {
411 outputFile = outputFilePath.getAbsolutePath();
412 }
413 }
414
415
416 // Look up configuration for this Quarto project, if this file is part of a Quarto book or
417 // website.
418 std::string websiteDir, websiteOutputDir;
419 auto projectMeta = inspect.find("project");
420 if (projectMeta != inspect.end())
421 {
422 FilePath quartoConfig = quartoProjectConfigFile(qmdPath);
423 if (!quartoConfig.isEmpty())
424 {
425 std::string type, outputDir;
426 readQuartoProjectConfig(quartoConfig, &type, &outputDir);
427 if (type == kQuartoProjectBook || type == kQuartoProjectSite)
428 {
429 FilePath configPath = quartoConfig.getParent();
430 websiteDir = configPath.getAbsolutePath();
431 // Infer output directory
432 if (outputDir.empty())
433 {
434 if (type == kQuartoProjectBook)
435 {
436 outputDir = "_book";
437 }
438 else
439 {
440 outputDir = "_site";
441 }
442 }
443 websiteOutputDir = configPath.completeChildPath(outputDir).getAbsolutePath();
444 }
445 }
446 }
447
448
449 // Attempt to determine whether or not the user has an active publishing account; used on the
450 // client to trigger an account setup step if necessary
451 r::sexp::Protect protect;
452 SEXP sexpHasAccount;
453 bool hasAccount = true;
454 error = r::exec::RFunction(".rs.hasConnectAccount").call(&sexpHasAccount, &protect);
455 if (error)
456 {
457 LOG_WARNING_MESSAGE("Couldn't determine whether Connect account is present");
458 LOG_ERROR(error);
459 }
460 else
461 {
462 hasAccount = r::sexp::asLogical(sexpHasAccount);
463 }
464
465
466 // Build result object
467 result["is_self_contained"] = selfContained;
468 result["title"] = title;
469 result["is_shiny_qmd"] = isShinyQmd;
470 result["website_dir"] = websiteDir;
471 result["website_output_dir"] = websiteOutputDir;
472 result["has_connect_account"] = hasAccount;
473 result["output_file"] = outputFile;
474
475 pResponse->setResult(result);
476
477 return Success();
478 }
479
rs_quartoFileResources(SEXP targetSEXP)480 SEXP rs_quartoFileResources(SEXP targetSEXP)
481 {
482 std::vector<std::string> resources;
483 std::string target = r::sexp::safeAsString(targetSEXP);
484 if (!target.empty())
485 {
486 FilePath qmdPath = module_context::resolveAliasedPath(target);
487 json::Object jsonInspect;
488 Error error = quartoInspect(
489 string_utils::utf8ToSystem(qmdPath.getAbsolutePath()), &jsonInspect
490 );
491 if (!error)
492 {
493 jsonInspect["resources"].getArray().toVectorString(resources);
494 }
495 else
496 {
497 LOG_ERROR(error);
498 }
499 }
500
501 r::sexp::Protect protect;
502 return r::sexp::create(resources, &protect);
503 }
504
quartoCreateProject(const json::JsonRpcRequest & request,json::JsonRpcResponse * pResponse)505 Error quartoCreateProject(const json::JsonRpcRequest& request,
506 json::JsonRpcResponse* pResponse)
507 {
508 std::string projectFile;
509 json::Object projectOptionsJson;
510 Error error = json::readParams(request.params,
511 &projectFile,
512 &projectOptionsJson);
513 if (error)
514 return error;
515 FilePath projectFilePath = module_context::resolveAliasedPath(projectFile);
516
517 // error if the dir already exists
518 FilePath projDir = projectFilePath.getParent();
519 if (projDir.exists())
520 return core::fileExistsError(ERROR_LOCATION);
521
522 // now create it
523 error = projDir.ensureDirectory();
524 if (error)
525 return error;
526
527 std::string type, engine, kernel, venv, packages;
528 error = json::readObject(projectOptionsJson,
529 "type", type,
530 "engine", engine,
531 "kernel", kernel,
532 "venv", venv,
533 "packages", packages);
534 if (error)
535 {
536 LOG_ERROR(error);
537 return error;
538 }
539
540 // add some first run files
541 using namespace module_context;
542 std::vector<std::string> projFiles;
543 if (type == kQuartoProjectSite || type == kQuartoProjectBook)
544 {
545 projFiles.push_back("index.qmd");
546 projFiles.push_back("_quarto.yml");
547 }
548 else
549 {
550 projFiles.push_back(projDir.getFilename() + ".qmd");
551 }
552 projects::addFirstRunDocs(projectFilePath, projFiles);
553
554 // create the project file
555 using namespace projects;
556 error = r_util::writeProjectFile(projectFilePath,
557 ProjectContext::buildDefaults(),
558 ProjectContext::defaultConfig());
559 if (error)
560 LOG_ERROR(error);
561
562
563 // create-project command
564 std::vector<std::string> args({
565 "create-project",
566 string_utils::utf8ToSystem(projDir.getAbsolutePath())
567 });
568
569 // project type (optional)
570 if (!type.empty())
571 {
572 args.push_back("--type");
573 args.push_back(type);
574 }
575
576 // project engine/kernel (optional)
577 if (!engine.empty())
578 {
579 std::string qualifiedEngine = engine;
580 if (engine == "jupyter" && !kernel.empty())
581 qualifiedEngine = qualifiedEngine + ":" + kernel;
582 args.push_back("--engine");
583 args.push_back(qualifiedEngine);
584 }
585
586 // create venv (optional)
587 if (engine == "jupyter" && !venv.empty())
588 {
589 args.push_back("--with-venv");
590 if (!packages.empty())
591 {
592 std::vector<std::string> pkgVector;
593 boost::algorithm::split(pkgVector,
594 packages,
595 boost::algorithm::is_any_of(", "));
596 args.push_back(boost::algorithm::join(pkgVector, ","));
597 }
598 }
599
600 // create the console process
601 using namespace console_process;
602 core::system::ProcessOptions options = quartoOptions();
603 #ifdef _WIN32
604 options.detachProcess = true;
605 #endif
606 boost::shared_ptr<ConsoleProcessInfo> pCPI =
607 boost::make_shared<ConsoleProcessInfo>("Creating project...",
608 console_process::InteractionNever);
609 boost::shared_ptr<console_process::ConsoleProcess> pCP = ConsoleProcess::create(
610 string_utils::utf8ToSystem(s_quartoPath.getAbsolutePath()),
611 args,
612 options,
613 pCPI);
614
615 pResponse->setResult(pCP->toJson(console_process::ClientSerialization));
616 return Success();
617 }
618
619 } // anonymous namespace
620
621 namespace quarto {
622
quartoCapabilities()623 json::Value quartoCapabilities()
624 {
625 if (quartoConfig().installed)
626 {
627 core::system::ProcessResult result;
628 Error error = quartoExec({ "capabilities" }, &result);
629 if (error)
630 {
631 LOG_ERROR(error);
632 return json::Value();
633 }
634 json::Value jsonCapabilities;
635 error = jsonCapabilities.parse(result.stdOut);
636 if (error)
637 {
638 LOG_ERROR(error);
639 return json::Value();
640 }
641 if (!jsonCapabilities.isObject())
642 {
643 LOG_ERROR_MESSAGE("Unexpected quarto capabilities json: " + result.stdErr);
644 return json::Value();
645 }
646 return jsonCapabilities;
647 }
648 else
649 {
650 return json::Value();
651 }
652 }
653
654 // Given a path to a Quarto file (usually .qmd), attempt to inspect it
quartoInspect(const std::string & path,json::Object * pResultObject)655 Error quartoInspect(const std::string& path,
656 json::Object *pResultObject)
657 {
658 // Run quarto and retrieve metadata
659 std::string output;
660 core::system::ProcessResult result;
661 Error error = runQuarto({"inspect", path}, FilePath(), &result);
662 if (error)
663 {
664 return error;
665 }
666
667 // Parse JSON result
668 return pResultObject->parse(result.stdOut);
669 }
670
671
672
handleQuartoPreview(const core::FilePath & sourceFile,const core::FilePath & outputFile,const std::string & renderOutput,bool validateExtendedType)673 bool handleQuartoPreview(const core::FilePath& sourceFile,
674 const core::FilePath& outputFile,
675 const std::string& renderOutput,
676 bool validateExtendedType)
677 {
678 // don't do anyting if user prefs are set to no preview
679 if (prefs::userPrefs().rmdViewerType() == kRmdViewerTypeNone)
680 return false;
681
682 // don't do anything if there is no quarto
683 if (!quartoIsInstalled())
684 return false;
685
686 // don't do anything if this isn't a quarto doc
687 if (validateExtendedType)
688 {
689 std::string extendedType;
690 Error error = source_database::detectExtendedType(sourceFile, &extendedType);
691 if (error)
692 return false;
693 if (extendedType != kQuartoXt)
694 return false;
695 }
696
697 // if the current project is a site or book and this file is within it,
698 // then initiate a preview (one might be already running)
699 auto config = quartoConfig();
700 if ((config.project_type == kQuartoProjectSite || config.project_type == kQuartoProjectBook) &&
701 sourceFile.isWithin(module_context::resolveAliasedPath(config.project_dir)))
702 {
703 // preview the doc (but schedule it for later so we can get out of the onCompleted
704 // handler this was called from -- launching a new process in the supervisor when
705 // an old one is in the middle of executing onCompleted doesn't work
706 module_context::scheduleDelayedWork(boost::posix_time::milliseconds(10),
707 boost::bind(modules::quarto::serve::previewDoc,
708 renderOutput, outputFile),
709 false);
710 return true;
711 }
712
713 // if this file is within another quarto site or book project then no preview at all
714 // (as it will more than likely be broken)
715 FilePath configFile = quartoProjectConfigFile(sourceFile);
716 if (!configFile.isEmpty())
717 {
718 std::string type;
719 readQuartoProjectConfig(configFile, &type);
720 if (type == kQuartoProjectSite || type == kQuartoProjectBook)
721 return true;
722 }
723
724 // continue with preview
725 return false;
726 }
727
728 const char* const kQuartoCrossrefScope = "quarto-crossref";
729 const char* const kQuartoProjectDefault = "default";
730 const char* const kQuartoProjectSite = "site";
731 const char* const kQuartoProjectBook = "book";
732
733
quartoConfig(bool refresh)734 QuartoConfig quartoConfig(bool refresh)
735 {
736 static QuartoConfig s_quartoConfig;
737
738 if (refresh)
739 {
740 detectQuartoInstallation();
741 s_quartoConfig = QuartoConfig();
742 s_quartoConfig.installed = quartoIsInstalled();
743 s_quartoConfig.version = s_quartoVersion;
744 using namespace session::projects;
745 const ProjectContext& context = projectContext();
746 if (context.hasProject())
747 {
748 // look for a config file in the project directory
749 FilePath configFile = quartoConfigFilePath(context.directory());
750
751 // if we don't find one, then chase up the directory heirarchy until we find one
752 if (!configFile.exists())
753 configFile = quartoProjectConfigFile(context.directory());
754
755 if (configFile.exists())
756 {
757 // confirm that it's a project
758 s_quartoConfig.is_project = true;
759
760 // record the project directory as an aliased path
761 s_quartoConfig.project_dir = module_context::createAliasedPath(configFile.getParent());
762
763 // read additional config from yaml
764 readQuartoProjectConfig(configFile,
765 &s_quartoConfig.project_type,
766 &s_quartoConfig.project_output_dir,
767 &s_quartoConfig.project_formats,
768 &s_quartoConfig.project_bibliographies);
769
770 // provide default output dirs
771 if (s_quartoConfig.project_output_dir.length() == 0)
772 {
773 if (s_quartoConfig.project_type == kQuartoProjectSite)
774 s_quartoConfig.project_output_dir = "_site";
775 else if (s_quartoConfig.project_type == kQuartoProjectBook)
776 s_quartoConfig.project_output_dir = "_book";
777 }
778 }
779 }
780 }
781 return s_quartoConfig;
782 }
783
isFileInSessionQuartoProject(const core::FilePath & file)784 bool isFileInSessionQuartoProject(const core::FilePath& file)
785 {
786 QuartoConfig config = quartoConfig();
787 if (config.is_project)
788 {
789 FilePath projDir = module_context::resolveAliasedPath(config.project_dir);
790 return file.isWithin(projDir);
791 }
792 else
793 {
794 return false;
795 }
796
797 }
798
quartoConfigJSON(bool refresh)799 json::Object quartoConfigJSON(bool refresh)
800 {
801 QuartoConfig config = quartoConfig(refresh);
802 json::Object quartoConfigJSON;
803 quartoConfigJSON["installed"] = config.installed;
804 quartoConfigJSON["version"] = config.version;
805 quartoConfigJSON["is_project"] = config.is_project;
806 quartoConfigJSON["project_dir"] = config.project_dir;
807 quartoConfigJSON["project_type"] = config.project_type;
808 quartoConfigJSON["project_output_dir"] = config.project_output_dir;
809 quartoConfigJSON["project_formats"] = json::toJsonArray(config.project_formats);
810 return quartoConfigJSON;
811 }
812
quartoBinary()813 FilePath quartoBinary()
814 {
815 return s_quartoPath;
816 }
817
projectIsQuarto()818 bool projectIsQuarto()
819 {
820 using namespace session::projects;
821 const ProjectContext& context = projectContext();
822 if (context.hasProject())
823 {
824 return quartoConfig().is_project;
825 } else {
826 return false;
827 }
828 }
829
quartoProjectConfigFile(const core::FilePath & filePath)830 FilePath quartoProjectConfigFile(const core::FilePath& filePath)
831 {
832 // list all paths up to root from home dir
833 // if we hit the anchor path, or any parent directory of that path
834 FilePath anchorPath = module_context::userHomePath();
835 std::vector<FilePath> anchorPaths;
836 for (; anchorPath.exists(); anchorPath = anchorPath.getParent())
837 anchorPaths.push_back(anchorPath);
838
839 // scan through parents
840 for (FilePath targetPath = filePath.getParent(); targetPath.exists(); targetPath = targetPath.getParent())
841 {
842 // bail if we've hit our anchor
843 for (const FilePath& anchorPath : anchorPaths)
844 {
845 if (targetPath == anchorPath)
846 return FilePath();
847 }
848
849 // see if we have a config
850 FilePath configFile = quartoConfigFilePath(targetPath);
851 if (!configFile.isEmpty())
852 return configFile;
853 }
854
855 return FilePath();
856 }
857
readQuartoProjectConfig(const FilePath & configFile,std::string * pType,std::string * pOutputDir,std::vector<std::string> * pFormats,std::vector<std::string> * pBibliographies)858 void readQuartoProjectConfig(const FilePath& configFile,
859 std::string* pType,
860 std::string* pOutputDir,
861 std::vector<std::string>* pFormats,
862 std::vector<std::string>* pBibliographies)
863 {
864 // read the config
865 std::string configText;
866 Error error = core::readStringFromFile(configFile, &configText);
867 if (!error)
868 {
869 try
870 {
871 YAML::Node node = YAML::Load(configText);
872 if (!node.IsMap())
873 {
874 LOG_ERROR_MESSAGE("Unexpected type for config file yaml (expected a map)");
875 return;
876 }
877 for (auto it = node.begin(); it != node.end(); ++it)
878 {
879 std::string key = it->first.as<std::string>();
880 if (key == "project" && it->second.Type() == YAML::NodeType::Map)
881 {
882 for (auto projIt = it->second.begin(); projIt != it->second.end(); ++projIt)
883 {
884 if (projIt->second.Type() == YAML::NodeType::Scalar)
885 {
886 std::string projKey = projIt->first.as<std::string>();
887 std::string projValue = projIt->second.Scalar();
888 if (projKey == "type")
889 *pType = projValue;
890 else if (projKey == "output-dir" && pOutputDir != nullptr)
891 *pOutputDir = projValue;
892 }
893 }
894 }
895 else if (key == "format" && pFormats != nullptr)
896 {
897 auto node = it->second;
898 if (node.Type() == YAML::NodeType::Scalar)
899 {
900 pFormats->push_back(node.as<std::string>());
901 }
902 else if (node.Type() == YAML::NodeType::Map)
903 {
904 for (auto formatIt = node.begin(); formatIt != node.end(); ++formatIt)
905 {
906 pFormats->push_back(formatIt->first.as<std::string>());
907 }
908 }
909 }
910 else if (key == "bibliography" && pBibliographies != nullptr)
911 {
912 auto node = it->second;
913 if (node.Type() == YAML::NodeType::Scalar)
914 {
915 pBibliographies->push_back(node.as<std::string>());
916 }
917 else if (node.Type() == YAML::NodeType::Sequence)
918 {
919 for (auto formatIt = node.begin(); formatIt != node.end(); ++formatIt)
920 {
921 pBibliographies->push_back(formatIt->as<std::string>());
922 }
923 }
924 }
925 }
926 }
927 CATCH_UNEXPECTED_EXCEPTION
928 }
929 else
930 {
931 LOG_ERROR(error);
932 }
933 }
934
935
936
937 } // namesace quarto
938
939 namespace module_context {
940
jupyterErrorLineNumber(const std::vector<std::string> & srcLines,const std::string & output)941 int jupyterErrorLineNumber(const std::vector<std::string>& srcLines, const std::string& output)
942 {
943 // strip ansi codes before searching
944 std::string plainOutput = output;
945 text::stripAnsiCodes(&plainOutput);
946
947 static boost::regex jupypterErrorRe("An error occurred while executing the following cell:\\s+(-{3,})\\s+([\\S\\s]+?)\\r?\\n(\\1)[\\S\\s]+line (\\d+)\\)");
948 boost::smatch matches;
949 if (regex_utils::search(plainOutput, matches, jupypterErrorRe))
950 {
951 // extract the cell lines
952 std::string cellText = matches[2].str();
953 string_utils::convertLineEndings(&cellText, string_utils::LineEndingPosix);
954 std::vector<std::string> cellLines = algorithm::split(cellText, "\n");
955
956 // strip out leading yaml (reading/writing of yaml can lead to src differences)
957 int yamlLines = 0;
958 for (auto line : cellLines)
959 {
960 if (boost::algorithm::starts_with(line, "#| "))
961 yamlLines++;
962 else
963 break;
964 }
965 cellLines = std::vector<std::string>(cellLines.begin() + yamlLines, cellLines.end());
966
967 // find the line number of the cell
968 auto it = std::search(srcLines.begin(), srcLines.end(), cellLines.begin(), cellLines.end());
969 if (it != srcLines.end())
970 {
971 int cellLine = static_cast<int>(std::distance(srcLines.begin(), it));
972 return cellLine + safe_convert::stringTo<int>(matches[4].str(), 0) - yamlLines;
973 }
974 }
975
976 // no error
977 return -1;
978 }
979
980 } // namespace module_context
981
982
983 namespace modules {
984 namespace quarto {
985
initialize()986 Error initialize()
987 {
988 RS_REGISTER_CALL_METHOD(rs_quartoFileResources, 1);
989
990 // initialize config at startup
991 quartoConfig(true);
992
993 module_context::events().onDetectSourceExtendedType
994 .connect(onDetectQuartoSourceType);
995
996 // additional initialization
997 ExecBlock initBlock;
998 initBlock.addFunctions()
999 (boost::bind(module_context::registerRpcMethod, "quarto_capabilities", quartoCapabilitiesRpc))
1000 (boost::bind(module_context::registerRpcMethod, "get_qmd_publish_details", getQmdPublishDetails))
1001 (boost::bind(module_context::registerRpcMethod, "quarto_create_project", quartoCreateProject))
1002 (boost::bind(module_context::sourceModuleRFile, "SessionQuarto.R"))
1003 (boost::bind(quarto::preview::initialize))
1004 (boost::bind(quarto::serve::initialize))
1005 (boost::bind(quarto::xrefs::initialize))
1006 ;
1007 return initBlock.execute();
1008 }
1009
1010 } // namespace quarto
1011 } // namespace modules
1012 } // namespace session
1013 } // namespace rstudio
1014