RawStyleParser.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.style.parser2;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import net.sourceforge.plantuml.style.PName;
import net.sourceforge.plantuml.style.StyleScheme;
import net.sourceforge.plantuml.style.parser.StyleParsingException;
import net.sourceforge.plantuml.utils.BlocLines;
import net.sourceforge.plantuml.utils.CharInspector;

/**
 * Turns the raw text of a .skin file into a {@link RawStyleSheet}: a literal tree of
 * {@code selector { ... }} blocks, with no attempt yet at merging duplicate selectors,
 * expanding comma lists, or resolving {@code @media} into a cascade -- see
 * {@link RawStyleRule}. That second pass, building the actual in-memory, queryable style
 * model, is left for later.
 *
 * Grammar handled here, ignoring whitespace and comments ({@code //}, {@code /* *}{@code
 * /} and {@code /' '/}) between tokens:
 *
 * <pre>
 *   sheet        ::= statement*
 *   statement    ::= rule | atMediaRule | variableDecl | propertyDecl
 *   rule         ::= ':'? selectorList '*'? '{' statement* '}'
 *   atMediaRule  ::= '@' anyCharExceptBraceOrSemicolon* '{' statement* '}'
 *   selectorList ::= name (',' name)*
 *   variableDecl ::= '--' name ':'? value (';' | end-of-line)
 *   propertyDecl ::= name ':'? value (';' | end-of-line)
 * </pre>
 */
public final class RawStyleParser {

	// Matches var(--name), var(-name) or var(name), the way CssVariables already tolerated it.
	private static final Pattern VAR_REF = Pattern.compile("var\\(\\s*-*([A-Za-z_][-\\w]*)\\s*\\)");

	private final CharInspector ins;
	private final Map<String, String> variables = new LinkedHashMap<String, String>();
	private final Deque<RawStyleRule> stack = new ArrayDeque<RawStyleRule>();
	private final List<RawStyleRule> topLevel = new ArrayList<RawStyleRule>();

	private RawStyleParser(CharInspector ins) {
		this.ins = ins;
	}

	public static RawStyleSheet parse(BlocLines lines) throws StyleParsingException {
		if (lines.size() == 0)
			return new RawStyleSheet(new LinkedHashMap<String, String>(), new ArrayList<RawStyleRule>());

		return new RawStyleParser(lines.inspectorWithNewlines()).run();
	}

	private RawStyleSheet run() throws StyleParsingException {
		while (true) {
			skipWhitespaceAndComments();
			final char c = ins.peek(0);
			if (c == '\0')
				break;

			if (c == '}') {
				ins.jump();
				closeCurrentRule();
			} else if (c == '@') {
				parseAtRule();
			} else if (c == ':') {
				// A selector may be written with a leading colon, e.g. ":foo {".
				ins.jump();
				parseSelectorHead(readIdentifier());
			} else {
				parseStatementStartingWithIdentifier();
			}
		}

		// The legacy character-level tokenizer never checked for this at all: reaching
		// end-of-file with a selector block still open was simply the end of parsing, not an
		// error -- whatever was nested inside that block and DID get its own closing brace (an
		// "arrow { ... }" inside an unclosed "activityDiagram { ... ") was already linked to it
		// the moment that child's own '}' was seen (see closeCurrentRule), so nothing is lost by
		// not raising an error here, only by discarding that parent along with the file's
		// missing brace. Close every remaining open rule now instead, exactly as if the missing
		// '}' characters had actually been there.
		while (stack.isEmpty() == false)
			closeCurrentRule();

		return new RawStyleSheet(variables, topLevel);
	}

	// -----------------------------------------------------------------------
	// Statements
	// -----------------------------------------------------------------------

	private void parseStatementStartingWithIdentifier() throws StyleParsingException {
		final String head = readIdentifier();
		if (head.length() == 0)
			throw new StyleParsingException("Unexpected character '" + ins.peek(0) + "'");

		if (head.startsWith("--")) {
			parseVariableDeclaration(head);
			return;
		}

		skipWhitespaceAndComments();
		final char next = ins.peek(0);
		if (next == ',' || next == '*' || next == '{')
			parseSelectorHead(head);
		else
			parsePropertyDeclaration(head);
	}

	private void parseSelectorHead(String firstName) throws StyleParsingException {
		final List<String> names = new ArrayList<String>();
		names.add(firstName);

		skipWhitespaceAndComments();
		while (ins.peek(0) == ',') {
			ins.jump();
			skipWhitespaceAndComments();
			names.add(readIdentifier());
			skipWhitespaceAndComments();
		}

		boolean star = false;
		if (ins.peek(0) == '*') {
			star = true;
			ins.jump();
			skipWhitespaceAndComments();
		}

		if (ins.peek(0) != '{')
			throw new StyleParsingException("Expected '{' after selector " + names);
		ins.jump();

		final List<RawSelector> selectors = new ArrayList<RawSelector>();
		for (String name : names)
			selectors.add(RawSelector.classify(name));

		stack.push(RawStyleRule.forSelectors(selectors, star));
	}

	private void parseAtRule() throws StyleParsingException {
		ins.jump(); // consume '@'
		final StringBuilder header = new StringBuilder();
		while (true) {
			final char c = ins.peek(0);
			if (c == '\0' || c == '}' || c == ';')
				throw new StyleParsingException("Unterminated @ rule: '@" + header + "'");
			if (c == '{')
				break;
			header.append(c);
			ins.jump();
		}
		ins.jump(); // consume '{'
		stack.push(RawStyleRule.forMedia(header.toString().trim()));
	}

	private void parseVariableDeclaration(String name) {
		final String value = readValue();
		variables.put(name.substring(2), resolveVariables(value));
	}

	private void parsePropertyDeclaration(String name) throws StyleParsingException {
		if (stack.isEmpty())
			throw new StyleParsingException("Property '" + name + "' declared outside of any block");

		final String rawValue = readValue();
		final PName pname = PName.getFromName(name, StyleScheme.REGULAR);
		if (pname == null)
			return; // Unknown property name: silently ignored, as the legacy parser already did.

		stack.peek().putProperty(pname, resolveVariables(rawValue));
	}

	/**
	 * A stray {@code }} with nothing open to close is silently ignored, rather than raising an
	 * error -- exactly like the legacy character-level tokenizer, which never even had the
	 * chance to notice one: its own {@code Context#pop()} is always guarded by
	 * {@code Context#isEmpty()} first ({@code StyleParser}'s {@code CLOSE_BRACKET} handling
	 * only pops "if (context.isEmpty() == false)"), so an extra closing brace back at the
	 * top level was always a silent no-op, never a parse error. A real-world {@code <style>}
	 * block with exactly this shape -- one closing brace too many, right before
	 * {@code </style>} -- must keep parsing exactly as it did before.
	 */
	private void closeCurrentRule() {
		if (stack.isEmpty())
			return;

		final RawStyleRule closed = stack.pop();
		if (stack.isEmpty())
			topLevel.add(closed);
		else
			stack.peek().addChild(closed);
	}

	// -----------------------------------------------------------------------
	// Lexical helpers
	// -----------------------------------------------------------------------

	/**
	 * Reads everything up to (not including) the next {@code ;}, end-of-line or {@code }} --
	 * except for a double-quoted segment (e.g. {@code FontName "Cascadia Code PL"}, needed to
	 * carry a space through as part of one value): its surrounding quotes are dropped and its
	 * content copied verbatim, exactly like the legacy character-level tokenizer's own
	 * {@code readQuotedString} does before its value-joining step ever sees the text, so a quoted
	 * value doesn't reach a caller with its literal quote characters still attached (which is
	 * indistinguishable from a font name that simply doesn't exist).
	 */
	private String readValue() {
		skipInlineSpaces();
		while (ins.peek(0) == ':')
			ins.jump();
		skipInlineSpaces();

		final StringBuilder sb = new StringBuilder();
		while (true) {
			final char c = ins.peek(0);
			if (c == '\0' || c == '\n' || c == '\r' || c == '}')
				break;
			if (c == ';') {
				ins.jump();
				break;
			}
			if (c == '\"') {
				ins.jump();
				while (ins.peek(0) != '\0' && ins.peek(0) != '\"') {
					sb.append(ins.peek(0));
					ins.jump();
				}
				if (ins.peek(0) == '\"')
					ins.jump();
				continue;
			}
			sb.append(c);
			ins.jump();
		}
		return sb.toString().trim();
	}

	/**
	 * Reads one bare identifier, or a double-quoted string; stops before any delimiter --
	 * except that a name starting with {@code '.'} (a stereotype selector, e.g.
	 * {@code .static lib { ... }}) is allowed to carry literal embedded spaces, exactly like
	 * the legacy character-level tokenizer's own {@code StyleParser#readString}, whose only
	 * break condition on a space is {@code ch == ' ' && result.charAt(0) != '.'} -- a dot-led
	 * token never stops at a space there, only at a "harder" delimiter (tab, newline, '{',
	 * '}', ';', ',', ':', '*', or end of input), and gets {@code trim()}-ed once read. Without
	 * this, ".static lib {" is misread as the single-word selector ".static" followed by a
	 * bare "lib" that belongs to neither a selector nor a property, which is exactly the real
	 * user-visible regression this reproduces: "Property '.static' declared outside of any
	 * block", instead of the two-word stereotype name the legacy parser always accepted.
	 */
	private String readIdentifier() {
		if (ins.peek(0) == '\"')
			return readQuoted();

		final boolean stereotypeLed = ins.peek(0) == '.';
		final StringBuilder sb = new StringBuilder();
		while (true) {
			final char c = ins.peek(0);
			if (c == ' ' && stereotypeLed) {
				sb.append(c);
				ins.jump();
				continue;
			}
			if (isDelimiter(c))
				break;
			sb.append(c);
			ins.jump();
		}
		return stereotypeLed ? sb.toString().trim() : sb.toString();
	}

	private String readQuoted() {
		ins.jump(); // opening quote
		final StringBuilder sb = new StringBuilder();
		while (ins.peek(0) != '\0' && ins.peek(0) != '\"') {
			sb.append(ins.peek(0));
			ins.jump();
		}
		if (ins.peek(0) == '\"')
			ins.jump();
		return sb.toString();
	}

	private static boolean isDelimiter(char c) {
		// '*' included: unlike the legacy character-level tokenizer (where '*' is always its
		// own token, so "name*" and "name *" tokenize identically), readIdentifier() would
		// otherwise swallow a directly-attached trailing '*' into the name itself -- silently
		// losing the star altogether, since neither the selector-list star check just below
		// nor RawSelector#classify's "depth(n)" pattern (which requires the string to end in
		// ')') would then recognize it. "depth(2)*", written without a space, is exactly the
		// real hand-written ancestor-cascade catch-all syntax this must not break.
		return c == '\0' || c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '{' || c == '}' || c == ';'
				|| c == ',' || c == ':' || c == '*';
	}

	private void skipInlineSpaces() {
		while (ins.peek(0) == ' ' || ins.peek(0) == '\t')
			ins.jump();
	}

	private void skipWhitespaceAndComments() {
		while (true) {
			final char c = ins.peek(0);
			if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
				ins.jump();
			} else if (c == '/' && ins.peek(1) == '/') {
				skipUntil('\n');
			} else if (c == '/' && ins.peek(1) == '*') {
				ins.jump();
				ins.jump();
				skipUntilSequence('*', '/');
			} else if (c == '/' && ins.peek(1) == '\'') {
				ins.jump();
				ins.jump();
				skipUntilSequence('\'', '/');
			} else {
				return;
			}
		}
	}

	private void skipUntil(char end) {
		while (ins.peek(0) != '\0' && ins.peek(0) != end)
			ins.jump();
	}

	private void skipUntilSequence(char first, char second) {
		while (ins.peek(0) != '\0') {
			if (ins.peek(0) == first && ins.peek(1) == second) {
				ins.jump();
				ins.jump();
				return;
			}
			ins.jump();
		}
	}

	/** Replaces every {@code var(--name)} reference with the value learnt for {@code name}. */
	private String resolveVariables(String value) {
		if (value.indexOf("var(") == -1)
			return value;

		final Matcher m = VAR_REF.matcher(value);
		final StringBuffer sb = new StringBuffer();
		while (m.find()) {
			final String varName = m.group(1);
			final String replacement = variables.containsKey(varName) ? variables.get(varName) : m.group();
			m.appendReplacement(sb, Matcher.quoteReplacement(replacement));
		}
		m.appendTail(sb);
		return sb.toString();
	}

}