FileLoaderJs.java

/* ========================================================================
 * PlantUML : a free UML diagram generator
 * ========================================================================
 *
 * (C) Copyright 2009-2024, Arnaud Roques
 *
 * Project Info:  https://plantuml.com
 *
 * If you like this project or if you find it useful, you can support us at:
 *
 * https://plantuml.com/patreon (only 1$ per month!)
 * https://plantuml.com/paypal
 *
 * This file is part of PlantUML.
 *
 * PlantUML is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * PlantUML distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
 * License for more details.
 *
 * You should have received a copy of the GNU General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301,
 * USA.
 *
 *
 * Original Author:  Arnaud Roques
 *
 *
 */

package net.sourceforge.plantuml.teavm.js.browser;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.io.StringReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;

import org.teavm.jso.JSBody;
import org.teavm.jso.JSFunctor;
import org.teavm.jso.JSObject;

import net.sourceforge.plantuml.nio.InputFile;
import net.sourceforge.plantuml.nio.NFolder;

/**
 * Reads the file of a local <code>!include</code>, <code>!include_once</code>,
 * <code>!include_many</code> or <code>!includesub</code>, or of a
 * <code>!theme ... from</code> a local folder, in the browser engine, through
 * a function the host provides.
 * <p>
 * The browser engine has no file system, so on its own such an include can
 * only fail. A host that does have files (an editor extension, a desktop
 * application, a documentation build running the engine) sets
 * <code>PLANTUML_FILE_LOADER</code> on the global object:
 *
 * <pre>
 * globalThis.PLANTUML_FILE_LOADER = function (path, from, onOk, onErr, request) { ... };
 * </pre>
 * <ul>
 * <li><code>path</code>: the file name the directive asks for, after variables
 * are expanded and a selector (<code>file!tag</code>) is split off; for
 * <code>!theme NAME from DIR</code>, <code>DIR/puml-theme-NAME.puml</code>.
 * It is handed over as it is: the engine resolves nothing.</li>
 * <li><code>from</code>: the file whose include is being resolved —
 * <code>null</code> for the diagram itself, otherwise the identifier the host
 * gave to the delivered file being evaluated, so that a relative name can be
 * resolved against that file. A local theme keeps its caller's
 * <code>from</code> for directives in its body, matching the Java build:
 * <code>null</code> if the diagram called it, or the calling file's identifier.
 * A standard-library file or a bundled theme being
 * evaluated never asks the host (see
 * {@link net.sourceforge.plantuml.nio.PathSystem#withoutHostFiles()}).</li>
 * <li><code>onOk(id, text)</code>: delivers the file. <code>id</code> is any
 * non-empty string that identifies it for the host (typically its absolute
 * path or URI), compared as it is. The include strategies count the identifier
 * together with the selector, so two diagrams of one file are two includes.
 * The body of an include or an includesub uses this identifier as
 * <code>from</code>; a local theme instead keeps its caller's context.</li>
 * <li><code>onErr(reason)</code>: the include fails. The reason is written to
 * the console.</li>
 * <li><code>request.kind</code>: <code>"include"</code> (also for
 * <code>!include_once</code> and <code>!include_many</code>),
 * <code>"includesub"</code> or <code>"theme"</code>. Hosts can distinguish
 * resource policies and diagnostics without inferring the operation from
 * the file name. This describes this request, not whether the file's contents
 * will be evaluated. The host still delivers the whole file; the engine
 * handles selectors, sub extraction and repeated includes. Existing loaders
 * can ignore the fifth argument.</li>
 * </ul>
 * The first outcome wins: a second callback, an exception or a rejection after
 * it changes nothing. Returning <code>false</code> (strictly) before either
 * callback declines the file, which then fails as if no loader were set, and a
 * callback arriving later is ignored. A loader may be an <code>async</code>
 * function: the rejection of the promise it returns fails the include, while
 * the value it fulfils with is ignored, so a file is only ever delivered
 * through <code>onOk</code>. A host that neither calls a callback, nor
 * returns <code>false</code>, nor rejects within
 * {@link PendingResources#ROUND_TIMEOUT_MS} makes the include fail, with a
 * message saying so; a later answer is ignored. The engine never reads anything
 * itself: which files may be read is decided by the host.
 * <p>
 * The engine never waits for the host: the diagram is rendered, the include
 * fails while its file is not there yet, and the diagram is rendered again
 * once the host has answered (see {@link PendingResources}). So the loader is
 * called once per file, kind and <code>from</code> during one rendering, before
 * the rendering whose result is delivered, and again at the next rendering.
 * <p>
 * The same shape as <code>PLANTUML_STDLIB_LOADER</code> in
 * {@link ScriptLoaderJs}. With no loader set, a local include fails, and
 * so does an includesub of a file, which used to be ignored in the browser.
 * An includesub of a section in the diagram itself needs no loader.
 */
public final class FileLoaderJs {
	// ::remove file when JAVA8

	@JSFunctor
	public interface Loaded extends JSObject {
		void invoke(String id, String text);
	}

	/**
	 * Whether the host has set <code>PLANTUML_FILE_LOADER</code>.
	 */
	@JSBody(params = {}, script = "var g = (typeof globalThis !== 'undefined') ? globalThis"
			+ " : ((typeof self !== 'undefined') ? self : this);"
			+ "return typeof g.PLANTUML_FILE_LOADER === 'function';")
	private static native boolean isAvailable();

	/**
	 * Asks the host for a file. Returns <code>false</code> when the host declines
	 * it before any callback; <code>true</code> otherwise, and then exactly one
	 * of the two callbacks is called later, or never when the host never answers.
	 * A missing identifier, a text that is not a
	 * string, an exception, a rejected promise and a reason that cannot be turned
	 * into a string all become a failure; anything after the first outcome is
	 * ignored.
	 */
	@JSBody(params = { "path", "from", "kind", "onOk", "onErr" }, script = "var g = (typeof globalThis !== 'undefined') ? globalThis"
			+ " : ((typeof self !== 'undefined') ? self : this);"
			+ "var settled = false;"
			+ "var reasonOf = function(e) {"
			+ "  try { return (e && e.message !== undefined) ? String(e.message) : ((e === undefined || e === null) ? '' : String(e)); }"
			+ "  catch (x) { return ''; }"
			+ "};"
			+ "var fail = function(reason) {"
			+ "  if (settled) return;"
			+ "  settled = true;"
			+ "  onErr(reasonOf(reason) || ('PLANTUML_FILE_LOADER failed for ' + path));"
			+ "};"
			+ "var ok = function(id, text) {"
			+ "  if (settled) return;"
			+ "  if (typeof id !== 'string' || id === '' || typeof text !== 'string') {"
			+ "    fail('PLANTUML_FILE_LOADER must pass a non-empty string id and a string text for ' + path); return;"
			+ "  }"
			+ "  settled = true; onOk(id, text);"
			+ "};"
			+ "var result;"
			+ "try { result = g.PLANTUML_FILE_LOADER(path, from, ok, fail, { kind: kind }); }"
			+ "catch (e) { fail(e); return true; }"
			+ "if (result !== null && (typeof result === 'object' || typeof result === 'function')) {"
			+ "  try { Promise.resolve(result).then(null, fail); } catch (e) { fail(e); }"
			+ "}"
			+ "if (result === false && settled === false) { settled = true; return false; }"
			+ "return true;")
	static native boolean load(String path, String from, String kind, Loaded onOk, ScriptLoaderJs.Err onErr);

	/**
	 * The file the host delivered for <code>path</code>, or <code>null</code> when
	 * no loader is set, when the host declines, when it fails (the reason is
	 * written to the console, and the include then reports the usual "cannot
	 * include"), or when the host has not been asked yet: the file is then noted
	 * as missing, and the diagram is rendered again once the host has answered
	 * (see {@link PendingResources}). Never waits.
	 *
	 * @param path   the file name the directive asks for
	 * @param folder where the include is written: <code>null</code> for the
	 *               diagram itself, or the folder of a file this loader
	 *               delivered. Any other folder has no host to ask.
	 * @param kind   <code>"include"</code>, <code>"includesub"</code> or
	 *               <code>"theme"</code>
	 */
	public static InputFile getInputFile(String path, NFolder folder, String kind) {
		if (isAvailable() == false)
			return null;
		if (folder != null && folder instanceof HostFolder == false)
			return null;

		final String from = folder == null ? null : ((HostFolder) folder).from;
		final PendingResources.Answer answer = PendingResources.file(path, from, kind);
		if (answer == null || answer.isDeclined())
			return null;

		if (answer.getError() != null) {
			ScriptLoaderJs.consoleWarn("PlantUML: cannot include " + path + ": " + answer.getError());
			return null;
		}
		return new HostFile(answer.getId(), answer.getText());
	}

	/**
	 * The identifier the host gave to a file this loader delivered, or
	 * <code>null</code> for any other file. It is what an include strategy
	 * compares, as it is: two identifiers are the same file only when the
	 * strings are equal.
	 */
	public static String getId(InputFile file) {
		if (file instanceof HostFile)
			return ((HostFile) file).id;

		return null;
	}

	private static final class HostFile implements InputFile {

		private final String id;
		private final String text;

		private HostFile(String id, String text) {
			this.id = id;
			this.text = text;
		}

		@Override
		public InputStream newInputStream() {
			return new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8));
		}

		@Override
		public Reader getReader(Charset charset) {
			// The host delivered text, not bytes: there is nothing to decode.
			return new StringReader(text);
		}

		@Override
		public NFolder getParentFolder() {
			return new HostFolder(id);
		}
	}

	/**
	 * Marks the includes written in a delivered file: it carries that file's
	 * identifier, which the host receives as <code>from</code>, and nothing
	 * else. It is not a directory — only the host knows how identifiers map to
	 * directories — so the engine's own lookups through it fail as an ordinary
	 * "cannot include" rather than reaching the host with a name it would have
	 * normalised.
	 */
	private static final class HostFolder implements NFolder {

		private final String from;

		private HostFolder(String from) {
			this.from = from;
		}

		@Override
		public InputFile getInputFile(Path nameOrPath) throws IOException {
			throw new IOException("The browser engine resolves no path through a delivered file");
		}

		@Override
		public NFolder getSubfolder(Path nameOrPath) throws IOException {
			throw new IOException("The browser engine resolves no path through a delivered file");
		}
	}

	private FileLoaderJs() {
	}
}