package com.github.czyzby.lml.parser.impl; import com.badlogic.gdx.scenes.scene2d.Actor; import com.badlogic.gdx.utils.Array; import com.github.czyzby.kiwi.util.common.Nullables; import com.github.czyzby.kiwi.util.common.Strings; import com.github.czyzby.kiwi.util.gdx.collection.GdxArrays; import com.github.czyzby.lml.parser.LmlData; import com.github.czyzby.lml.parser.LmlParser; import com.github.czyzby.lml.parser.LmlStyleSheet; import com.github.czyzby.lml.parser.LmlSyntax; import com.github.czyzby.lml.parser.LmlTemplateReader; import com.github.czyzby.lml.parser.impl.tag.macro.util.Equation; import com.github.czyzby.lml.parser.tag.LmlTag; import com.github.czyzby.lml.parser.tag.LmlTagProvider; import com.github.czyzby.lml.util.LmlParsingException; import com.github.czyzby.lml.util.LmlUtilities; /** Default implementation of {@link LmlParser}. * * @author MJ */ public class DefaultLmlParser extends AbstractLmlParser { /** Helper variable. Keeps reference to currently nearest nested parent. Never a macro tag. */ private LmlTag currentParentTag; /** Parsing result, cleared after each parsing. Kept as field to support {@link #addActor(Actor)}. */ private Array<Actor> actors; /** Creates a new strict parser with default syntax and reader. * * @param data contains skin, actions, i18n bundles and other data needed to parse LML templates. */ public DefaultLmlParser(final LmlData data) { super(data, new DefaultLmlSyntax(), new DefaultLmlTemplateReader(), new DefaultLmlStyleSheet(), true); } /** Creates a new strict parser with custom syntax and default reader. * * @param data contains skin, actions, i18n bundles and other data needed to parse LML templates. * @param syntax determines syntax of LML templates. */ public DefaultLmlParser(final LmlData data, final LmlSyntax syntax) { super(data, syntax, new DefaultLmlTemplateReader(), new DefaultLmlStyleSheet(), true); } /** Creates a new strict parser with custom syntax and reader. * * @param data contains skin, actions, i18n bundles and other data needed to parse LML templates. * @param syntax determines syntax of LML templates. * @param templateReader reads and buffers templates and their files. */ public DefaultLmlParser(final LmlData data, final LmlSyntax syntax, final LmlTemplateReader templateReader) { super(data, syntax, templateReader, new DefaultLmlStyleSheet(), true); } /** Creates a new strict parser with custom syntax, reader and style sheet. * * @param data contains skin, actions, i18n bundles and other data needed to parse LML templates. * @param syntax determines syntax of LML templates. * @param templateReader reads and buffers templates and their files. * @param styleSheet contains default values of attributes. */ public DefaultLmlParser(final LmlData data, final LmlSyntax syntax, final LmlTemplateReader templateReader, final LmlStyleSheet styleSheet) { super(data, syntax, templateReader, styleSheet, true); } /** Creates a new parser with custom syntax, reader and strict setting. * * @param data contains skin, actions, i18n bundles and other data needed to parse LML templates. * @param syntax determines syntax of LML templates. * @param templateReader reads and buffers templates and their files. * @param strict if false, will ignore some unexpected errors, like unknown attributes, invalid referenced method * names etc. Set to true for more HTML-like feel or quick prototyping. */ public DefaultLmlParser(final LmlData data, final LmlSyntax syntax, final LmlTemplateReader templateReader, final boolean strict) { super(data, syntax, templateReader, new DefaultLmlStyleSheet(), strict); } /** Creates a new strict parser with custom syntax, reader, style sheet and strict setting. * * @param data contains skin, actions, i18n bundles and other data needed to parse LML templates. * @param syntax determines syntax of LML templates. * @param templateReader reads and buffers templates and their files. * @param styleSheet contains default values of attributes. * @param strict if false, will ignore some unexpected errors, like unknown attributes, invalid referenced method * names etc. Set to true for more HTML-like feel or quick prototyping. */ public DefaultLmlParser(final LmlData data, final LmlSyntax syntax, final LmlTemplateReader templateReader, final LmlStyleSheet styleSheet, final boolean strict) { super(data, syntax, templateReader, styleSheet, strict); } @Override public void addActor(final Actor actor) { if (actors == null) { throw new IllegalStateException("Actors can be added to result collection only during parsing."); } actors.add(actor); mapActorById(actor); } @Override protected Array<Actor> parseTemplate() { try { return parse(); } catch (final LmlParsingException exception) { // Expected exception. throw exception; } catch (final Exception exception) { // Unexpected exception. Rethrowing as our own to point the error line. throwError("Unable to parse passed template due to an unexpected exception.", exception); return null; } finally { currentParentTag = null; // Making sure there are no tags from invalid templates. templateReader.clear(); // If an exception was thrown, we no longer want to parse the template content; // if no error occurred, template reader has no data and this is a safe no-op. actors = null; // Nulling out helper actors array reference. This is the parsing result we want to return. } } /** Does the actual parsing * * @return actor parsed from LML template currently stored in the template reader. */ protected Array<Actor> parse() { actors = GdxArrays.newArray(Actor.class); invokePreListeners(actors); final StringBuilder builder = new StringBuilder(); while (templateReader.hasNextCharacter()) { final char character = templateReader.nextCharacter(); if (character == syntax.getArgumentOpening()) { // Found an argument opening. This needs to be replaced. processArgument(); } else if (character == syntax.getTagOpening()) { // Tag was just opened. This might be a comment, though. if (isNextCharacterCommentOpening()) { processComment(); continue; } // This is not a comment, since we're there. Parsing a new tag. if (currentParentTag != null) { currentParentTag.handleDataBetweenTags(builder); } Strings.clearBuilder(builder); processTag(builder); } else { // Just your regular letter outside of a tag: builder.append(character); } } if (currentParentTag != null) { throwError('"' + currentParentTag.getTagName() + "\" tag was never closed."); } invokePortListeners(actors); return actors; } /** Found an argument opening sign. Have to find argument's name and replace it in the template. */ private void processArgument() { final StringBuilder argumentBuilder = new StringBuilder(); while (templateReader.hasNextCharacter()) { final char argumentCharacter = templateReader.nextCharacter(); if (argumentCharacter == syntax.getArgumentClosing()) { final String argument = argumentBuilder.toString().trim(); // Getting actual argument name. if (Strings.startsWith(argument, syntax.getEquationMarker())) { // Starts with an equation sign. Evaluating. final String equation = LmlUtilities.stripMarker(argument); templateReader.append(newEquation().getResult(equation), equation + " equation"); } else if (Strings.startsWith(argument, syntax.getConditionMarker())) { // Condition/ternary operator. Evaluating. processConditionArgument(argument, argumentBuilder); } else { // Regular argument. Looking for value mapped to the selected key. templateReader.append(Nullables.toString(data.getArgument(argument)), argument + " argument"); } return; } argumentBuilder.append(argumentCharacter); } } /** Utility factory method. * * @return a new {@link Equation} instance. */ protected Equation newEquation() { return new Equation(this, getCurrentActor()); } /** @return actor from the currently open parent tag or null. */ protected Actor getCurrentActor() { return currentParentTag == null ? null : currentParentTag.getActor(); } /** @param argument raw condition argument data. Starts with condition marker (?). * @param conditionBuilder utility builder. Will be modified, possibly cleared. */ private void processConditionArgument(final String argument, final StringBuilder conditionBuilder) { if (argument.lastIndexOf(syntax.getConditionMarker()) <= 0) { throwError("No separator in condition: " + argument); } Strings.clearBuilder(conditionBuilder); boolean parsedCondition = false; boolean parsedOnTrue = false; String condition = null; String onTrue = null; String onFalse = Strings.EMPTY_STRING; for (int index = 1, length = argument.length(); index < length; index++) { final char character = argument.charAt(index); if (!parsedCondition) { if (character == syntax.getConditionMarker()) { parsedCondition = true; condition = Strings.getAndClear(conditionBuilder).trim(); continue; } } else if (!parsedOnTrue && character == syntax.getTernaryMarker()) { parsedOnTrue = true; onTrue = Strings.getAndClear(conditionBuilder).trim(); continue; } conditionBuilder.append(character); } if (parsedOnTrue) { onFalse = conditionBuilder.toString().trim(); } else { onTrue = conditionBuilder.toString().trim(); } final boolean result = newEquation().getBooleanResult(condition); final String append = result ? onTrue : onFalse; if (Strings.isNotEmpty(append)) { templateReader.append(newEquation().getResult(append), argument + " condition"); } } /** Found an open tag starting with comment sign. Burning through characters up to the comment's end. */ private void processComment() { templateReader.nextCharacter(); // Burning comment opening char. if (templateReader.startsWith(syntax.getDocumentTypeOpening())) { processSchemaComment(); return; } else if (nestedComments) { processNestedComment(); return; } while (templateReader.hasNextCharacter()) { final char commentCharacter = templateReader.nextCharacter(); if (isCommentClosingMarker(commentCharacter) && templateReader.hasNextCharacter() && templateReader.peekCharacter() == syntax.getTagClosing()) { // Character was a comment closing sign and the next tag closed the comment. templateReader.nextCharacter(); // Polling tag closing. break; } } } /** Found a comment starting with DOCTYPE. Burning through the characters. */ private void processSchemaComment() { // Removing DOCTYPE: burnCharacters(syntax.getDocumentTypeOpening().length()); int tagsOpened = 1; while (templateReader.hasNextCharacter()) { final char character = templateReader.nextCharacter(); // Schema comment can define new entities, see http://www.w3schools.com/xml/xml_dtd.asp if (character == syntax.getTagOpening()) { tagsOpened++; } else if (character == syntax.getTagClosing()) { if (--tagsOpened == 0) { break; } } } } /** Found an open tag starting with comment sign and nested comments are on. Burning through characters up to the * comment's end, honoring nested, commented-out comments. */ private void processNestedComment() { int nestedCommentsAmount = 1; // Starting with just the initial comment. while (templateReader.hasNextCharacter()) { final char commentCharacter = templateReader.nextCharacter(); if (isCommentClosingMarker(commentCharacter) && templateReader.hasNextCharacter() && templateReader.peekCharacter() == syntax.getTagClosing()) { // Character was a comment closing sign and the next tag closed the comment. templateReader.nextCharacter(); // Polling tag closing. if (--nestedCommentsAmount == 0) { // All comments ended. Returning. break; } } if (commentCharacter == syntax.getTagOpening() && templateReader.hasNextCharacter() && isCommentOpeningMarker(templateReader.peekCharacter())) { // Another comment was opened inside the currently parsed one. templateReader.nextCharacter(); // Polling comment opening marker. nestedCommentsAmount++; } } } /** Found an open tag that is not a comment. Collecting whole tag data. * * @param builder will be used to collect data. */ private void processTag(final StringBuilder builder) { boolean started = false; while (templateReader.hasNextCharacter()) { final char tagCharacter = templateReader.nextCharacter(); // Stripping whitespaces at the beginning of the tag data: if (!started && Strings.isWhitespace(tagCharacter)) { continue; } started = true; if (tagCharacter == syntax.getArgumentOpening()) { // LML parser argument inside a tag. We want to convert these. processArgument(); } else if (tagCharacter == syntax.getTagOpening() && isNextCharacterCommentOpening()) { // Comment inside a tag. Removing its content. processComment(); } else if (tagCharacter == syntax.getTagClosing()) { // We're being closed. processTagEntity(builder); return; } else { builder.append(tagCharacter); } } throwError("Unclosed tag: " + builder.toString()); } /** @param rawTagData collected, unparsed LML tag data. Might be a macro or a widget. Might be used to process * additional data. Will be cleared. */ private void processTagEntity(final StringBuilder rawTagData) { final int tagNameEndIndex = getTagNameEndIndex(rawTagData); if (Strings.startsWith(rawTagData, syntax.getClosedTagMarker())) { // This is a closing tag (</tag>). Trying to close current parent. processClosedTag(rawTagData, tagNameEndIndex); } else if (Strings.startsWith(rawTagData, syntax.getMacroMarker())) { // Uh-oh, this is a macro. Macros handle their content themselves, so we need to process them differently. final String macroName = LmlUtilities // Stripping last character if the tag is immediately closed: <:tag/>. .stripEnding(rawTagData.substring(1, tagNameEndIndex), syntax.getClosedTagMarker()).trim(); processMacro(macroName, rawTagData); } else { // Regular tag. final String tagName = LmlUtilities // Stripping last character if the tag is immediately closed: <tag/>. .stripEnding(rawTagData.substring(0, tagNameEndIndex), syntax.getClosedTagMarker()).trim(); processRegularTag(tagName, rawTagData); } Strings.clearBuilder(rawTagData); } /** @param rawTagData unparsed LML tag data. * @return index that marks the end of tag's name. */ private static int getTagNameEndIndex(final StringBuilder rawTagData) { final int length = rawTagData.length(); int whitespaceIndex = Strings.CHARACTER_UNAVAILABLE; for (int index = 0; index < length; index++) { if (Strings.isWhitespace(rawTagData.charAt(index))) { whitespaceIndex = index; break; } } if (Strings.isCharacterPresent(whitespaceIndex)) { // At least one whitespace is present in the string, so we assume that the data before first space is tag's // name: return whitespaceIndex; } // There are no whitespaces, so we assume that the whole tag data is its name: return length; } /** @param rawTagData unprocessed data of the currently closed tag. * @param tagNameEndIndex index in rawTagData at which tag name ends. */ private void processClosedTag(final StringBuilder rawTagData, final int tagNameEndIndex) { // Starting with 1 char to strip '/' marker: final String closedTagName = rawTagData.substring(1, tagNameEndIndex).trim(); if (currentParentTag == null) { throwErrorIfStrict("There were no open tags, and yet: \"" + closedTagName + "\" is a closed parental tag."); return; } else if (!currentParentTag.getTagName().equals(closedTagName)) { if (strict || !strict && !currentParentTag.getTagName().equalsIgnoreCase(closedTagName)) { throwError("Tag: \"" + closedTagName + "\" was closed, but: \"" + currentParentTag.getTagName() + "\" was expected."); } } currentParentTag.closeTag(); final LmlTag grandParent = currentParentTag.getParent(); if (grandParent == null) { // Tag was a root. if (currentParentTag.getActor() != null) { actors.add(currentParentTag.getActor()); } } else { // Tag had a parent. grandParent.handleChild(currentParentTag); } mapActorById(currentParentTag.getActor()); currentParentTag = grandParent; } /** @param macroName name of the macro tag to be parsed. * @param rawTagData raw data of the macro tag. Will be used to append data between macro tags. It will be modified, * but should be cleared manually after calling this method. */ private void processMacro(final String macroName, final StringBuilder rawTagData) { final LmlTagProvider tagProvider = syntax.getMacroTagProvider(macroName); if (tagProvider == null) { throwError("No macro tag provider found for name: " + macroName); } final LmlTag macroTag = tagProvider.create(this, currentParentTag, rawTagData); Strings.clearBuilder(rawTagData); if (macroTag.isChild()) { // Immediately closing the tag, since it's a child. macroTag.closeTag(); return; } int sameNameNestedMacrosAmount = 1; // We start with our own macro, hence 1. final StringBuilder helperBuilder = new StringBuilder(); while (templateReader.hasNextCharacter()) { final char macroCharacter = templateReader.nextCharacter(); if (macroCharacter == syntax.getTagOpening() && templateReader.hasNextCharacter()) { // Another tag was opened. This might be macro's end or a nested macro - we need to handle both. final char nextMacroCharacter = templateReader.peekCharacter(); if (nextMacroCharacter == syntax.getClosedTagMarker()) { // A tag is being closed. We need to investigate if this is our tag. final int sameMacroTag = isSameMacroTagName(macroTag, helperBuilder); if (sameMacroTag > -1 && --sameNameNestedMacrosAmount == 0) { // We're done. All nested macros with the same name are parsed. burnCharacters(sameMacroTag); break; } } else if (nextMacroCharacter == syntax.getMacroMarker()) { // A macro is being open. We need to investigate if it has the same tag name. if (isSameMacroTagName(macroTag, helperBuilder) > -1) { sameNameNestedMacrosAmount++; } } } // No macro tag is currently being open or the opening is irrelevant. Appending character. rawTagData.append(macroCharacter); } if (sameNameNestedMacrosAmount > 0) { throwError("Macro tag not closed: " + macroTag.getTagName()); } if (Strings.isNotEmpty(rawTagData)) { macroTag.handleDataBetweenTags(rawTagData); } macroTag.closeTag(); } /** @param charactersAmount amount of characters to be removed from the reader. */ private void burnCharacters(final int charactersAmount) { for (int index = 0; index < charactersAmount; index++) { templateReader.nextCharacter(); } } /** @param macroTag is currently parsed and a same name tag is currently being closed or open right now. * @param tagNameBuilder helper builder to construct macro tag name. * @return true (>-1) if a macro with the same name is currently being closed or opened. The returned value is also * the amount of characters that should be burned if the macro closing tag actually belongs to the currently * parsed macro and not one of its nested children. */ private int isSameMacroTagName(final LmlTag macroTag, final StringBuilder tagNameBuilder) { int additionalIndexesToPeek = 0; if (templateReader.hasNextCharacter() && templateReader.peekCharacter() == syntax.getClosedTagMarker()) { // The tag is currently closed. Looking for macro sign on the next index. additionalIndexesToPeek++; } if (!templateReader.hasNextCharacter(additionalIndexesToPeek) || templateReader.peekCharacter(additionalIndexesToPeek++) != syntax.getMacroMarker()) { // This is a regular tag, so names cannot match. return -1; } Strings.clearBuilder(tagNameBuilder); while (templateReader.hasNextCharacter(additionalIndexesToPeek)) { final char character = templateReader.peekCharacter(additionalIndexesToPeek++); if (character == syntax.getTagClosing() || character == syntax.getClosedTagMarker() || Strings.isWhitespace(character)) { // Whitespaces separate tag name from attributes and cannot be escaped in tag names. If the tag is // currently being closed or its a whitespace, we've got the whole tag name. But this can happen: // <:macro><:macro attribute /></:macro> // If we return now, we tell the parser that a nested macro is inside this one, even though the macro // itself was closed as soon as it was opened. We need to check if macro is not a child. if ((Strings.isWhitespace(character) || character == syntax.getClosedTagMarker()) && isCurrentMacroTagChild(additionalIndexesToPeek - 1)) { return -1; } final String closedTagName = tagNameBuilder.toString().trim(); if (macroTag.getTagName().equals(closedTagName) || !strict && macroTag.getTagName().equalsIgnoreCase(closedTagName)) { return additionalIndexesToPeek; } // Names don't match. return -1; } // Tag is not closed yet. Appending character. tagNameBuilder.append(character); } // Tag never closed. Exception will probably be thrown in the future, but we don't care for now. return -1; } /** @param additionalIndexesToPeek current character pointer to check. * @return true if currently parsed macro tag is a child. */ private boolean isCurrentMacroTagChild(int additionalIndexesToPeek) { while (templateReader.hasNextCharacter(additionalIndexesToPeek)) { final char character = templateReader.peekCharacter(additionalIndexesToPeek++); if (character == syntax.getClosedTagMarker() && isLastCharacterInMacroTag(additionalIndexesToPeek)) { // '/' found before tag was closed. This is might be a child. return true; } else if (character == syntax.getTagClosing()) { // Tag was closed without '/' marker. This is a parent. return false; } } return false; } /** @param additionalIndexesToPeek current character pointer to check. * @return if the additionalIndexesToPeek-1 char is the last one in the tag - it contains only whitespace chars and * the tag closing marker right after it. */ private boolean isLastCharacterInMacroTag(int additionalIndexesToPeek) { while (templateReader.hasNextCharacter(additionalIndexesToPeek)) { final char character = templateReader.peekCharacter(additionalIndexesToPeek++); if (character == syntax.getTagClosing()) { // Found tag closing after the character. return true; } else if (!Strings.isWhitespace(character)) { // Found different char before the tag was closed. return false; } } return false; } /** @param tagName name of the tag to be parsed. * @param rawTagData raw data of a regular widget tag. */ private void processRegularTag(final String tagName, final StringBuilder rawTagData) { final LmlTagProvider tagProvider = syntax.getTagProvider(tagName); if (tagProvider == null) { throwError("No tag parser found for name: " + tagName); } final LmlTag tag = tagProvider.create(this, currentParentTag, rawTagData); if (tag.isParent()) { currentParentTag = tag; } else { // The tag is a child, so we're closing it immediately. tag.closeTag(); if (currentParentTag != null) { // Tag is child - adding to current parent: currentParentTag.handleChild(tag); } else { // Tag is a root - adding to the result: if (tag.getActor() != null) { actors.add(tag.getActor()); } } mapActorById(tag.getActor()); } } }