From 95408b6dae2c623050303d45d53ad573bbfaab71 Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Thu, 11 Jun 2026 22:44:29 +0200
Subject: [PATCH 01/10] feat: add user defined grammar parser
---
src/backend/grammar_parser.hpp | 124 +++++++++
src/gui/grammareditordialog.cpp | 467 ++++++++++++++++++++++++++++++++
2 files changed, 591 insertions(+)
create mode 100644 src/backend/grammar_parser.hpp
create mode 100644 src/gui/grammareditordialog.cpp
diff --git a/src/backend/grammar_parser.hpp b/src/backend/grammar_parser.hpp
new file mode 100644
index 00000000..a47009ff
--- /dev/null
+++ b/src/backend/grammar_parser.hpp
@@ -0,0 +1,124 @@
+/*
+ * SyntaxTutor - Interactive Tutorial About Syntax Analyzers
+ * Copyright (C) 2025 Jose R. (jose-rzm)
+ *
+ * This program 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.
+ *
+ * This program is 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 program. If not, see .
+ */
+#pragma once
+
+#include "grammar.hpp"
+#include
+#include
+
+/**
+ * @struct GrammarParseError
+ * @brief Describes one problem found while parsing user-written grammar text.
+ *
+ * Errors carry a machine-readable kind, the 1-based line where the offending
+ * rule starts, and the offending fragment so the GUI can build a localized,
+ * user-friendly message.
+ */
+struct GrammarParseError {
+ /**
+ * @enum Kind
+ * @brief Machine-readable category of a grammar text error.
+ */
+ enum class Kind {
+ /// @brief The text contains no rules at all.
+ EmptyGrammar,
+ /// @brief A rule has no "->" separator.
+ MissingArrow,
+ /// @brief Text after the last "." looks like an unterminated rule.
+ MissingEndDot,
+ /// @brief A rule has no symbol before "->".
+ EmptyLeftHandSide,
+ /// @brief A rule has more than one symbol before "->".
+ MultipleLeftHandSide,
+ /// @brief A rule body contains another "->", usually a missing dot.
+ ExtraArrow,
+ /// @brief A symbol uses a reserved character or reserved word.
+ ReservedSymbol,
+ };
+
+ /// @brief Category of the error.
+ Kind kind;
+
+ /// @brief 1-based line in the source text where the rule starts.
+ int line;
+
+ /// @brief Offending token or rule fragment, for diagnostics.
+ std::string detail;
+};
+
+/**
+ * @struct GrammarParseResult
+ * @brief Outcome of parsing user-written grammar text.
+ *
+ * When `errors` is empty, `grammar` holds a fully-built grammar: symbols are
+ * classified (non-terminals are exactly the left-hand sides), the axiom is
+ * the left-hand side of the first rule, and the grammar has been augmented
+ * with a fresh axiom rule `X -> $` following the app-wide convention.
+ */
+struct GrammarParseResult {
+ /// @brief Resulting grammar. Only meaningful when `errors` is empty.
+ Grammar grammar;
+
+ /// @brief All problems found in the text, in source order.
+ std::vector errors;
+
+ /// @brief True when parsing succeeded and `grammar` is usable.
+ bool Ok() const { return errors.empty(); }
+};
+
+/**
+ * @struct GrammarParser
+ * @brief Parses user-written grammar text into a Grammar object.
+ *
+ * Input format, kept deliberately flexible:
+ * - Each rule is `LHS -> SYMBOL SYMBOL ... .` and ends with a dot. Rules
+ * may span several lines and several rules may share one line.
+ * - Symbols are separated by whitespace; any whitespace-free string is a
+ * valid symbol, so multi-character tokens such as `id` or `function`
+ * are supported. `A b` is two symbols while `Ab` is one.
+ * - "→" is accepted as an alias of "->", with or without surrounding
+ * spaces.
+ * - Alternatives can be grouped with `|`: `A -> a | b .`
+ * - An empty right-hand side (`A -> .` or an empty `|` branch) denotes an
+ * epsilon production.
+ * - Non-terminals are exactly the symbols that appear on a left-hand
+ * side; every other symbol is a terminal. The first rule defines the
+ * axiom.
+ */
+struct GrammarParser {
+ /**
+ * @brief Parses grammar text into an augmented Grammar.
+ *
+ * @param text User-written grammar description.
+ * @return Parse result with either a ready-to-use grammar or the list
+ * of errors found.
+ */
+ static GrammarParseResult Parse(const std::string& text);
+
+ /**
+ * @brief Checks whether a token cannot be used as a grammar symbol.
+ *
+ * Reserved tokens are the meta symbols ($, EPSILON, ε, ·) and any token
+ * containing characters used by the tutors as separators or markers
+ * (dot, comma, colon, semicolon, pipe or an embedded arrow).
+ *
+ * @param token Token to check.
+ * @return true if the token is reserved and must be rejected.
+ */
+ static bool IsReservedToken(const std::string& token);
+};
diff --git a/src/gui/grammareditordialog.cpp b/src/gui/grammareditordialog.cpp
new file mode 100644
index 00000000..777a4c70
--- /dev/null
+++ b/src/gui/grammareditordialog.cpp
@@ -0,0 +1,467 @@
+/*
+ * SyntaxTutor - Interactive Tutorial About Syntax Analyzers
+ * Copyright (C) 2025 Jose R. (jose-rzm)
+ *
+ * This program 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.
+ *
+ * This program is 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 program. If not, see .
+ */
+
+#include "grammareditordialog.h"
+#include "grammar_factory.hpp"
+#include "grammar_parser.hpp"
+#include "ll1_parser.hpp"
+#include "slr1_parser.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+#ifdef SYNTAXTUTOR_TESTING
+constexpr auto kSettingsOrg = "UMA-Test";
+constexpr auto kSettingsApp = "SyntaxTutor-Test";
+#else
+constexpr auto kSettingsOrg = "UMA";
+constexpr auto kSettingsApp = "SyntaxTutor";
+#endif
+
+constexpr int kDebounceMs = 200;
+constexpr int kMaxListedErrors = 4;
+const char kLastGrammarKey[] = "userGrammar/lastText";
+
+QLabel* makeSummaryCaption(const QString& text, QWidget* parent) {
+ auto* label = new QLabel(text, parent);
+ label->setObjectName("grammarEditorSummaryCaption");
+ return label;
+}
+
+QLabel* makeSummaryValue(QWidget* parent) {
+ auto* label = new QLabel(parent);
+ label->setObjectName("grammarEditorSummaryValue");
+ label->setWordWrap(true);
+ label->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ return label;
+}
+} // namespace
+
+// ===================== GrammarSyntaxHighlighter ==========================
+
+GrammarSyntaxHighlighter::GrammarSyntaxHighlighter(QTextDocument* parent)
+ : QSyntaxHighlighter(parent) {
+ arrowFormat_.setForeground(QColor("#11B3BC"));
+ arrowFormat_.setFontWeight(QFont::Bold);
+ punctuationFormat_.setForeground(QColor("#9AA5A8"));
+ punctuationFormat_.setFontWeight(QFont::Bold);
+ nonTerminalFormat_.setForeground(QColor("#36C5CC"));
+ nonTerminalFormat_.setFontWeight(QFont::Bold);
+}
+
+void GrammarSyntaxHighlighter::setNonTerminals(
+ const QSet& nonTerminals) {
+ if (nonTerminals_ == nonTerminals) {
+ return;
+ }
+ nonTerminals_ = nonTerminals;
+ rehighlight();
+}
+
+void GrammarSyntaxHighlighter::highlightBlock(const QString& text) {
+ qsizetype tokenStart = -1;
+ auto closeToken = [&](qsizetype end) {
+ if (tokenStart < 0) {
+ return;
+ }
+ const QString token = text.mid(tokenStart, end - tokenStart);
+ if (nonTerminals_.contains(token)) {
+ setFormat(tokenStart, end - tokenStart, nonTerminalFormat_);
+ } else if (token == QStringLiteral("->") ||
+ token == QStringLiteral("→")) {
+ setFormat(tokenStart, end - tokenStart, arrowFormat_);
+ }
+ tokenStart = -1;
+ };
+
+ for (qsizetype i = 0; i < text.size(); ++i) {
+ const QChar c = text.at(i);
+ if (c.isSpace()) {
+ closeToken(i);
+ } else if (c == '.' || c == '|') {
+ closeToken(i);
+ setFormat(i, 1, punctuationFormat_);
+ } else {
+ if (tokenStart < 0) {
+ tokenStart = i;
+ }
+ }
+ }
+ closeToken(text.size());
+}
+
+// ======================== GrammarEditorDialog ============================
+
+GrammarEditorDialog::GrammarEditorDialog(Mode mode, QWidget* parent)
+ : QDialog(parent), mode_(mode) {
+ buildUi();
+
+ debounce_.setSingleShot(true);
+ debounce_.setInterval(kDebounceMs);
+ connect(&debounce_, &QTimer::timeout, this,
+ &GrammarEditorDialog::validateNow);
+ connect(input_, &QPlainTextEdit::textChanged, this,
+ &GrammarEditorDialog::scheduleValidation);
+
+ QSettings settings(kSettingsOrg, kSettingsApp);
+ const QString lastGrammar = settings.value(kLastGrammarKey).toString();
+ if (!lastGrammar.isEmpty()) {
+ input_->setPlainText(lastGrammar);
+ }
+ validateNow();
+}
+
+void GrammarEditorDialog::buildUi() {
+ setObjectName("grammarEditorDialog");
+ setProperty("grammarEditor", true);
+ setWindowTitle(mode_ == Mode::LL1 ? tr("Tu gramática — LL(1)")
+ : tr("Tu gramática — SLR(1)"));
+ setModal(true);
+ resize(780, 580);
+ setMinimumSize(660, 480);
+
+ auto* rootLayout = new QVBoxLayout(this);
+ rootLayout->setContentsMargins(28, 24, 28, 24);
+ rootLayout->setSpacing(16);
+
+ auto* eyebrow = new QLabel(mode_ == Mode::LL1 ? tr("EJERCICIO LL(1)")
+ : tr("EJERCICIO SLR(1)"),
+ this);
+ eyebrow->setObjectName("grammarEditorEyebrow");
+ rootLayout->addWidget(eyebrow);
+
+ auto* title = new QLabel(tr("Escribe tu gramática"), this);
+ title->setObjectName("grammarEditorTitle");
+ rootLayout->addWidget(title);
+
+ auto* subtitle = new QLabel(
+ tr("Cada regla termina con un punto y los símbolos se separan con "
+ "espacios. El primer antecedente es el axioma, los antecedentes "
+ "son los no terminales y el resto de símbolos son terminales. "
+ "Usa | para alternativas y deja el consecuente vacío para "
+ "épsilon (A -> .)."),
+ this);
+ subtitle->setObjectName("grammarEditorSubtitle");
+ subtitle->setWordWrap(true);
+ rootLayout->addWidget(subtitle);
+
+ auto* contentLayout = new QHBoxLayout();
+ contentLayout->setSpacing(16);
+
+ input_ = new QPlainTextEdit(this);
+ input_->setObjectName("grammarEditorInput");
+ QFont mono = QFontDatabase::systemFont(QFontDatabase::FixedFont);
+ mono.setPointSize(14);
+ input_->setFont(mono);
+ input_->setTabChangesFocus(true);
+ input_->setPlaceholderText(tr("E -> E + T | T .\nT -> ( E ) | id ."));
+ contentLayout->addWidget(input_, 1);
+
+ auto* summaryPanel = new QFrame(this);
+ summaryPanel->setObjectName("grammarEditorSummary");
+ summaryPanel->setFixedWidth(220);
+ auto* summaryLayout = new QVBoxLayout(summaryPanel);
+ summaryLayout->setContentsMargins(16, 14, 16, 14);
+ summaryLayout->setSpacing(4);
+
+ summaryLayout->addWidget(makeSummaryCaption(tr("AXIOMA"), summaryPanel));
+ axiomValue_ = makeSummaryValue(summaryPanel);
+ summaryLayout->addWidget(axiomValue_);
+ summaryLayout->addSpacing(8);
+
+ summaryLayout->addWidget(
+ makeSummaryCaption(tr("NO TERMINALES"), summaryPanel));
+ nonTermValue_ = makeSummaryValue(summaryPanel);
+ summaryLayout->addWidget(nonTermValue_);
+ summaryLayout->addSpacing(8);
+
+ summaryLayout->addWidget(
+ makeSummaryCaption(tr("TERMINALES"), summaryPanel));
+ termValue_ = makeSummaryValue(summaryPanel);
+ summaryLayout->addWidget(termValue_);
+ summaryLayout->addSpacing(8);
+
+ summaryLayout->addWidget(makeSummaryCaption(tr("REGLAS"), summaryPanel));
+ rulesValue_ = makeSummaryValue(summaryPanel);
+ summaryLayout->addWidget(rulesValue_);
+ summaryLayout->addStretch(1);
+
+ contentLayout->addWidget(summaryPanel);
+ rootLayout->addLayout(contentLayout, 1);
+
+ statusLabel_ = new QLabel(this);
+ statusLabel_->setObjectName("grammarEditorStatus");
+ statusLabel_->setWordWrap(true);
+ statusLabel_->setMinimumHeight(44);
+ rootLayout->addWidget(statusLabel_);
+
+ auto* footerLayout = new QHBoxLayout();
+ footerLayout->setSpacing(12);
+ footerLayout->addStretch(1);
+
+ cancelButton_ = new QPushButton(tr("Cancelar"), this);
+ cancelButton_->setObjectName("grammarEditorCancelButton");
+ cancelButton_->setAutoDefault(false);
+ cancelButton_->setCursor(Qt::PointingHandCursor);
+ footerLayout->addWidget(cancelButton_);
+
+ startButton_ = new QPushButton(tr("Comenzar ejercicio"), this);
+ startButton_->setObjectName("grammarEditorStartButton");
+ startButton_->setProperty("role", "primary");
+ startButton_->setAutoDefault(false);
+ startButton_->setEnabled(false);
+ footerLayout->addWidget(startButton_);
+
+ rootLayout->addLayout(footerLayout);
+
+ highlighter_ = new GrammarSyntaxHighlighter(input_->document());
+
+ connect(cancelButton_, &QPushButton::clicked, this, &QDialog::reject);
+ connect(startButton_, &QPushButton::clicked, this,
+ &GrammarEditorDialog::accept);
+
+ auto* submitShortcut =
+ new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Return), this);
+ connect(submitShortcut, &QShortcut::activated, this,
+ &GrammarEditorDialog::accept);
+
+ clearSummary();
+ input_->setFocus();
+}
+
+void GrammarEditorDialog::scheduleValidation() {
+ debounce_.start();
+}
+
+void GrammarEditorDialog::validateNow() {
+ debounce_.stop();
+ const QString text = input_->toPlainText();
+
+ GrammarParseResult result = GrammarParser::Parse(text.toStdString());
+ if (!result.Ok()) {
+ valid_ = false;
+ startButton_->setEnabled(false);
+ clearSummary();
+ const bool emptyText =
+ result.errors.size() == 1 &&
+ result.errors.front().kind == GrammarParseError::Kind::EmptyGrammar;
+ setStatus(describeParseErrors(result), emptyText ? "hint" : "error");
+ return;
+ }
+
+ updateSummary(result.grammar);
+
+ GrammarFactory checks;
+ if (checks.IsInfinite(result.grammar)) {
+ valid_ = false;
+ startButton_->setEnabled(false);
+ setStatus(tr("Hay símbolos que no generan ninguna cadena de "
+ "terminales. Revisa que cada no terminal tenga un caso "
+ "base."),
+ "error");
+ return;
+ }
+ if (checks.HasUnreachableSymbols(result.grammar)) {
+ valid_ = false;
+ startButton_->setEnabled(false);
+ setStatus(tr("Hay símbolos que no se pueden alcanzar desde el "
+ "axioma."),
+ "error");
+ return;
+ }
+
+ if (mode_ == Mode::LL1) {
+ LL1Parser ll1(result.grammar);
+ if (!ll1.CreateLL1Table()) {
+ valid_ = false;
+ startButton_->setEnabled(false);
+ setStatus(tr("La gramática no es LL(1): su tabla tiene "
+ "conflictos. Prueba a factorizar por la izquierda "
+ "o a eliminar la recursividad izquierda."),
+ "error");
+ return;
+ }
+ } else {
+ SLR1Parser slr1(result.grammar);
+ if (!slr1.MakeParser()) {
+ valid_ = false;
+ startButton_->setEnabled(false);
+ setStatus(tr("La gramática no es SLR(1): hay conflictos "
+ "shift/reduce o reduce/reduce."),
+ "error");
+ return;
+ }
+ }
+
+ grammar_ = result.grammar;
+ valid_ = true;
+ startButton_->setEnabled(true);
+
+ const QString axiom = QString::fromStdString(grammar_.axiom_);
+ const QString start =
+ QString::fromStdString(grammar_.g_.at(grammar_.axiom_).at(0).at(0));
+ setStatus(tr("Gramática %1 válida. Se añadirá la regla inicial "
+ "%2 → %3 $.")
+ .arg(mode_ == Mode::LL1 ? QStringLiteral("LL(1)")
+ : QStringLiteral("SLR(1)"),
+ axiom, start),
+ "ok");
+}
+
+void GrammarEditorDialog::updateSummary(const Grammar& grammar) {
+ const QString axiom = QString::fromStdString(grammar.axiom_);
+ const QString start =
+ QString::fromStdString(grammar.g_.at(grammar.axiom_).at(0).at(0));
+
+ QStringList nonTerminals;
+ QSet highlightSet;
+ for (const std::string& nt : grammar.st_.non_terminals_) {
+ const QString symbol = QString::fromStdString(nt);
+ highlightSet.insert(symbol);
+ if (symbol != axiom) {
+ nonTerminals.append(symbol);
+ }
+ }
+ std::sort(nonTerminals.begin(), nonTerminals.end());
+ nonTerminals.removeAll(start);
+ nonTerminals.prepend(start);
+
+ QStringList terminals;
+ for (const std::string& t : grammar.st_.terminals_wtho_eol_) {
+ terminals.append(QString::fromStdString(t));
+ }
+ std::sort(terminals.begin(), terminals.end());
+
+ int ruleCount = 0;
+ for (const auto& [lhs, productions] : grammar.g_) {
+ if (lhs != grammar.axiom_) {
+ ruleCount += static_cast(productions.size());
+ }
+ }
+
+ axiomValue_->setText(start);
+ nonTermValue_->setText(nonTerminals.join(QStringLiteral(" ")));
+ termValue_->setText(terminals.join(QStringLiteral(" ")));
+ rulesValue_->setText(QString::number(ruleCount));
+ highlighter_->setNonTerminals(highlightSet);
+}
+
+void GrammarEditorDialog::clearSummary() {
+ const QString dash = QStringLiteral("—");
+ axiomValue_->setText(dash);
+ nonTermValue_->setText(dash);
+ termValue_->setText(dash);
+ rulesValue_->setText(dash);
+}
+
+void GrammarEditorDialog::setStatus(const QString& message,
+ const QString& state) {
+ statusLabel_->setText(message);
+ if (statusLabel_->property("state").toString() != state) {
+ statusLabel_->setProperty("state", state);
+ statusLabel_->style()->unpolish(statusLabel_);
+ statusLabel_->style()->polish(statusLabel_);
+ }
+}
+
+QString
+GrammarEditorDialog::describeParseErrors(const GrammarParseResult& result) {
+ QStringList messages;
+ for (const GrammarParseError& error : result.errors) {
+ if (messages.size() == kMaxListedErrors) {
+ messages.append(tr("… y %1 errores más.")
+ .arg(result.errors.size() - kMaxListedErrors));
+ break;
+ }
+ QString detail = QString::fromStdString(error.detail).simplified();
+ if (detail.size() > 40) {
+ detail = detail.left(39) + QStringLiteral("…");
+ }
+ switch (error.kind) {
+ case GrammarParseError::Kind::EmptyGrammar:
+ messages.append(
+ tr("Escribe al menos una regla, por ejemplo: A -> a A | b ."));
+ break;
+ case GrammarParseError::Kind::MissingArrow:
+ messages.append(tr("Línea %1: falta la flecha «->» en «%2».")
+ .arg(error.line)
+ .arg(detail));
+ break;
+ case GrammarParseError::Kind::MissingEndDot:
+ messages.append(tr("Línea %1: falta el punto final en «%2».")
+ .arg(error.line)
+ .arg(detail));
+ break;
+ case GrammarParseError::Kind::EmptyLeftHandSide:
+ messages.append(
+ tr("Línea %1: falta el antecedente antes de la flecha.")
+ .arg(error.line));
+ break;
+ case GrammarParseError::Kind::MultipleLeftHandSide:
+ messages.append(tr("Línea %1: el antecedente debe ser un único "
+ "símbolo, no «%2».")
+ .arg(error.line)
+ .arg(detail));
+ break;
+ case GrammarParseError::Kind::ExtraArrow:
+ messages.append(tr("Línea %1: hay una flecha de más en «%2». "
+ "¿Olvidaste terminar la regla anterior con "
+ "un punto?")
+ .arg(error.line)
+ .arg(detail));
+ break;
+ case GrammarParseError::Kind::ReservedSymbol:
+ messages.append(tr("Línea %1: el símbolo «%2» está reservado o "
+ "contiene caracteres no permitidos "
+ "(. , : ; | $).")
+ .arg(error.line)
+ .arg(detail));
+ break;
+ }
+ }
+ return messages.join(QStringLiteral("\n"));
+}
+
+void GrammarEditorDialog::accept() {
+ if (!valid_) {
+ validateNow();
+ if (!valid_) {
+ return;
+ }
+ }
+ QSettings settings(kSettingsOrg, kSettingsApp);
+ settings.setValue(kLastGrammarKey, input_->toPlainText());
+ QDialog::accept();
+}
+
+#ifdef SYNTAXTUTOR_TESTING
+void GrammarEditorDialog::setGrammarTextForTest(const QString& text) {
+ input_->setPlainText(text);
+ validateNow();
+}
+#endif
From 5b90d187707adeec6a26449c9571ce997276faee Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Thu, 11 Jun 2026 22:45:07 +0200
Subject: [PATCH 02/10] feat: add grammar parser
---
src/backend/grammar_parser.cpp | 270 +++++++++++++++++++++++++++++++++
src/gui/grammareditordialog.h | 137 +++++++++++++++++
2 files changed, 407 insertions(+)
create mode 100644 src/backend/grammar_parser.cpp
create mode 100644 src/gui/grammareditordialog.h
diff --git a/src/backend/grammar_parser.cpp b/src/backend/grammar_parser.cpp
new file mode 100644
index 00000000..30e870d7
--- /dev/null
+++ b/src/backend/grammar_parser.cpp
@@ -0,0 +1,270 @@
+/*
+ * SyntaxTutor - Interactive Tutorial About Syntax Analyzers
+ * Copyright (C) 2025 Jose R. (jose-rzm)
+ *
+ * This program 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.
+ *
+ * This program is 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 program. If not, see .
+ */
+
+#include "grammar_parser.hpp"
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+constexpr std::string_view kArrow{"->"};
+constexpr std::string_view kArrowUtf8{"→"};
+constexpr std::string_view kDotUtf8{"·"};
+constexpr std::string_view kEpsilonUtf8{"ε"};
+
+struct RawRule {
+ std::string antecedent;
+ std::vector> productions;
+ int line;
+};
+
+bool IsBlank(char c) {
+ return c == ' ' || c == '\t' || c == '\r' || c == '\n';
+}
+
+bool IsBlankText(const std::string& s) {
+ return std::ranges::all_of(s, IsBlank);
+}
+
+std::string Trimmed(const std::string& s) {
+ size_t start = s.find_first_not_of(" \t\r\n");
+ if (start == std::string::npos) {
+ return {};
+ }
+ size_t end = s.find_last_not_of(" \t\r\n");
+ return s.substr(start, end - start + 1);
+}
+
+std::vector SplitOnBlanks(const std::string& s) {
+ std::vector tokens;
+ std::string current;
+ for (char c : s) {
+ if (IsBlank(c)) {
+ if (!current.empty()) {
+ tokens.push_back(std::move(current));
+ current.clear();
+ }
+ } else {
+ current.push_back(c);
+ }
+ }
+ if (!current.empty()) {
+ tokens.push_back(std::move(current));
+ }
+ return tokens;
+}
+
+std::string NormalizeArrows(const std::string& text) {
+ std::string normalized;
+ normalized.reserve(text.size());
+ size_t i = 0;
+ while (i < text.size()) {
+ if (text.compare(i, kArrowUtf8.size(), kArrowUtf8) == 0) {
+ normalized += kArrow;
+ i += kArrowUtf8.size();
+ } else {
+ normalized.push_back(text[i]);
+ ++i;
+ }
+ }
+ return normalized;
+}
+
+// Splits a rule body into |-separated alternatives, where an empty
+// alternative denotes an epsilon production.
+std::vector SplitAlternatives(const std::string& body) {
+ std::vector alternatives;
+ std::string current;
+ for (char c : body) {
+ if (c == '|') {
+ alternatives.push_back(current);
+ current.clear();
+ } else {
+ current.push_back(c);
+ }
+ }
+ alternatives.push_back(current);
+ return alternatives;
+}
+
+void ParseChunk(const std::string& chunk, int line, std::vector& rules,
+ std::vector& errors) {
+ size_t arrowPos = chunk.find(kArrow);
+ if (arrowPos == std::string::npos) {
+ errors.push_back(
+ {GrammarParseError::Kind::MissingArrow, line, Trimmed(chunk)});
+ return;
+ }
+
+ const std::string lhsText = chunk.substr(0, arrowPos);
+ const std::string body = chunk.substr(arrowPos + kArrow.size());
+
+ if (body.find(kArrow) != std::string::npos) {
+ errors.push_back(
+ {GrammarParseError::Kind::ExtraArrow, line, Trimmed(chunk)});
+ return;
+ }
+
+ const std::vector lhsTokens = SplitOnBlanks(lhsText);
+ if (lhsTokens.empty()) {
+ errors.push_back(
+ {GrammarParseError::Kind::EmptyLeftHandSide, line, Trimmed(chunk)});
+ return;
+ }
+ if (lhsTokens.size() > 1) {
+ errors.push_back({GrammarParseError::Kind::MultipleLeftHandSide, line,
+ Trimmed(lhsText)});
+ return;
+ }
+
+ RawRule rule{lhsTokens.front(), {}, line};
+ bool valid = true;
+ if (GrammarParser::IsReservedToken(rule.antecedent)) {
+ errors.push_back(
+ {GrammarParseError::Kind::ReservedSymbol, line, rule.antecedent});
+ valid = false;
+ }
+
+ for (const std::string& alternative : SplitAlternatives(body)) {
+ std::vector symbols = SplitOnBlanks(alternative);
+ for (const std::string& symbol : symbols) {
+ if (GrammarParser::IsReservedToken(symbol)) {
+ errors.push_back(
+ {GrammarParseError::Kind::ReservedSymbol, line, symbol});
+ valid = false;
+ }
+ }
+ rule.productions.push_back(std::move(symbols));
+ }
+
+ if (valid) {
+ rules.push_back(std::move(rule));
+ }
+}
+
+std::string FreshAxiomName(const SymbolTable& st, const std::string& axiom) {
+ if (!st.In("S")) {
+ return "S";
+ }
+ std::string candidate = axiom + "'";
+ while (st.In(candidate)) {
+ candidate += "'";
+ }
+ return candidate;
+}
+
+Grammar BuildGrammar(const std::vector& rules) {
+ Grammar gr;
+
+ for (const RawRule& rule : rules) {
+ gr.st_.PutSymbol(rule.antecedent, false);
+ }
+
+ std::set>> seen;
+ for (const RawRule& rule : rules) {
+ for (const std::vector& symbols : rule.productions) {
+ production prod = symbols;
+ if (prod.empty()) {
+ prod.push_back(gr.st_.EPSILON_);
+ }
+ for (const std::string& symbol : prod) {
+ if (symbol != gr.st_.EPSILON_) {
+ gr.st_.PutSymbol(symbol, !gr.st_.IsNonTerminal(symbol));
+ }
+ }
+ if (seen.insert({rule.antecedent, prod}).second) {
+ gr.AddProduction(rule.antecedent, prod);
+ }
+ }
+ }
+
+ const std::string userAxiom = rules.front().antecedent;
+ const std::string axiom = FreshAxiomName(gr.st_, userAxiom);
+ gr.st_.PutSymbol(axiom, false);
+ gr.AddProduction(axiom, {userAxiom, gr.st_.EOL_});
+ gr.SetAxiom(axiom);
+ return gr;
+}
+
+} // namespace
+
+bool GrammarParser::IsReservedToken(const std::string& token) {
+ if (token.empty() || token == "$" || token == "EPSILON" ||
+ token == kEpsilonUtf8 || token == kDotUtf8) {
+ return true;
+ }
+ if (token.find(kArrow) != std::string::npos ||
+ token.find(kDotUtf8) != std::string::npos) {
+ return true;
+ }
+ return std::ranges::any_of(token, [](char c) {
+ return c == '.' || c == ',' || c == ':' || c == ';' || c == '|' ||
+ c == '$';
+ });
+}
+
+GrammarParseResult GrammarParser::Parse(const std::string& text) {
+ const std::string normalized = NormalizeArrows(text);
+
+ std::vector rules;
+ std::vector errors;
+
+ std::string chunk;
+ int line = 1;
+ int chunkLine = 1;
+ bool chunkSeen = false;
+
+ for (char c : normalized) {
+ if (c == '.') {
+ if (!IsBlankText(chunk)) {
+ ParseChunk(chunk, chunkLine, rules, errors);
+ }
+ chunk.clear();
+ chunkSeen = false;
+ } else {
+ if (!chunkSeen && !IsBlank(c)) {
+ chunkLine = line;
+ chunkSeen = true;
+ }
+ chunk.push_back(c);
+ }
+ if (c == '\n') {
+ ++line;
+ }
+ }
+
+ if (!IsBlankText(chunk)) {
+ errors.push_back({GrammarParseError::Kind::MissingEndDot, chunkLine,
+ Trimmed(chunk)});
+ }
+
+ if (rules.empty() && errors.empty()) {
+ errors.push_back({GrammarParseError::Kind::EmptyGrammar, 1, ""});
+ }
+
+ GrammarParseResult result;
+ result.errors = std::move(errors);
+ if (result.errors.empty()) {
+ result.grammar = BuildGrammar(rules);
+ }
+ return result;
+}
diff --git a/src/gui/grammareditordialog.h b/src/gui/grammareditordialog.h
new file mode 100644
index 00000000..417423f2
--- /dev/null
+++ b/src/gui/grammareditordialog.h
@@ -0,0 +1,137 @@
+/*
+ * SyntaxTutor - Interactive Tutorial About Syntax Analyzers
+ * Copyright (C) 2025 Jose R. (jose-rzm)
+ *
+ * This program 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.
+ *
+ * This program is 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 program. If not, see .
+ */
+
+#ifndef GRAMMAREDITORDIALOG_H
+#define GRAMMAREDITORDIALOG_H
+
+#include "grammar.hpp"
+#include
+#include
+#include
+#include
+
+class QLabel;
+class QPlainTextEdit;
+class QPushButton;
+struct GrammarParseResult;
+
+/**
+ * @class GrammarSyntaxHighlighter
+ * @brief Lightweight highlighter for user-written grammar text.
+ *
+ * Colors the structural pieces of a grammar (arrows, rule dots and
+ * alternative pipes) and renders the currently-detected non-terminals in the
+ * accent color, so the user gets immediate feedback on how their text is
+ * being interpreted.
+ */
+class GrammarSyntaxHighlighter : public QSyntaxHighlighter {
+ Q_OBJECT
+ public:
+ explicit GrammarSyntaxHighlighter(QTextDocument* parent);
+
+ /**
+ * @brief Updates the set of symbols rendered as non-terminals.
+ *
+ * Triggers a rehighlight only when the set actually changes.
+ *
+ * @param nonTerminals Symbols currently classified as non-terminals.
+ */
+ void setNonTerminals(const QSet& nonTerminals);
+
+ protected:
+ void highlightBlock(const QString& text) override;
+
+ private:
+ QSet nonTerminals_;
+ QTextCharFormat arrowFormat_;
+ QTextCharFormat punctuationFormat_;
+ QTextCharFormat nonTerminalFormat_;
+};
+
+/**
+ * @class GrammarEditorDialog
+ * @brief Dialog where the user writes their own grammar for an exercise.
+ *
+ * The user types rules in the `LHS -> SYMBOL ... .` format. The text is
+ * parsed and validated live: format errors are reported with line numbers,
+ * the detected axiom / non-terminals / terminals are summarized, and the
+ * grammar is checked to be LL(1) or SLR(1) depending on the exercise the
+ * dialog was opened for. The last accepted grammar is persisted through
+ * QSettings so it can be practiced again.
+ */
+class GrammarEditorDialog : public QDialog {
+ Q_OBJECT
+ public:
+ /**
+ * @enum Mode
+ * @brief Exercise the grammar is being written for.
+ */
+ enum class Mode { LL1, SLR1 };
+
+ /**
+ * @brief Constructs the editor for the given exercise kind.
+ *
+ * @param mode Exercise the grammar must be valid for.
+ * @param parent Parent widget.
+ */
+ explicit GrammarEditorDialog(Mode mode, QWidget* parent = nullptr);
+
+ /**
+ * @brief Returns the validated, augmented grammar.
+ *
+ * Only meaningful after the dialog was accepted.
+ */
+ const Grammar& grammar() const { return grammar_; }
+
+#ifdef SYNTAXTUTOR_TESTING
+ /// @brief Testing hook: replaces the editor text and validates at once.
+ void setGrammarTextForTest(const QString& text);
+ /// @brief Testing hook: whether the current text passed all validations.
+ bool isGrammarValidForTest() const { return valid_; }
+#endif
+
+ protected:
+ void accept() override;
+
+ private slots:
+ void scheduleValidation();
+ void validateNow();
+
+ private:
+ void buildUi();
+ void updateSummary(const Grammar& grammar);
+ void clearSummary();
+ void setStatus(const QString& message, const QString& state);
+ QString describeParseErrors(const GrammarParseResult& result);
+
+ Mode mode_;
+ Grammar grammar_;
+ bool valid_ = false;
+ QTimer debounce_;
+ QPlainTextEdit* input_ = nullptr;
+ QLabel* statusLabel_ = nullptr;
+ QLabel* axiomValue_ = nullptr;
+ QLabel* nonTermValue_ = nullptr;
+ QLabel* termValue_ = nullptr;
+ QLabel* rulesValue_ = nullptr;
+ QPushButton* startButton_ = nullptr;
+ QPushButton* cancelButton_ = nullptr;
+ GrammarSyntaxHighlighter* highlighter_ = nullptr;
+};
+
+#endif // GRAMMAREDITORDIALOG_H
From 9b9a3a71069e4244a0c6bc957c08383637df81d3 Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Thu, 11 Jun 2026 22:45:36 +0200
Subject: [PATCH 03/10] chore: update config files
---
.github/workflows/sonarcloud.yml | 1 +
SyntaxTutor.pro | 4 ++++
2 files changed, 5 insertions(+)
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
index ef69d726..1b811d5a 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -36,6 +36,7 @@ jobs:
g++ -std=gnu++2a -O0 -g --coverage -fprofile-arcs -ftest-coverage \
src/backend/grammar.cpp \
src/backend/grammar_factory.cpp \
+ src/backend/grammar_parser.cpp \
src/backend/ll1_parser.cpp \
src/backend/lr0_item.cpp \
src/backend/slr1_parser.cpp \
diff --git a/SyntaxTutor.pro b/SyntaxTutor.pro
index 16932cda..5773675f 100644
--- a/SyntaxTutor.pro
+++ b/SyntaxTutor.pro
@@ -26,12 +26,14 @@ INCLUDEPATH += \
SOURCES += \
src/backend/grammar.cpp \
src/backend/grammar_factory.cpp \
+ src/backend/grammar_parser.cpp \
src/backend/ll1_parser.cpp \
src/backend/lr0_item.cpp \
src/backend/slr1_parser.cpp \
src/backend/symbol_table.cpp \
src/widgets/customtextedit.cpp \
src/widgets/grammarview.cpp \
+ src/gui/grammareditordialog.cpp \
src/gui/lltabledialog.cpp \
src/gui/lltutorwindow.cpp \
src/app/main.cpp \
@@ -45,6 +47,7 @@ HEADERS += \
src/appversion.h \
src/backend/grammar.hpp \
src/backend/grammar_factory.hpp \
+ src/backend/grammar_parser.hpp \
src/backend/ll1_parser.hpp \
src/backend/lr0_item.hpp \
src/backend/slr1_parser.hpp \
@@ -52,6 +55,7 @@ HEADERS += \
src/backend/symbol_table.hpp \
src/widgets/customtextedit.h \
src/widgets/grammarview.h \
+ src/gui/grammareditordialog.h \
src/gui/lltabledialog.h \
src/gui/lltutorwindow.h \
src/gui/mainwindow.h \
From d092f86a11f4279b0af945d2a8c9158b56ed1252 Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Thu, 11 Jun 2026 22:47:31 +0200
Subject: [PATCH 04/10] test: add grammar parser tests
---
src/backend/tests.cpp | 206 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 206 insertions(+)
diff --git a/src/backend/tests.cpp b/src/backend/tests.cpp
index 0f1a190f..29353383 100644
--- a/src/backend/tests.cpp
+++ b/src/backend/tests.cpp
@@ -18,6 +18,7 @@
#include "grammar.hpp"
#include "grammar_factory.hpp"
+#include "grammar_parser.hpp"
#include "ll1_parser.hpp"
#include "slr1_parser.hpp"
#include
@@ -3546,6 +3547,211 @@ TEST(SymbolTableTest, IsTerminalWthoEol_OnlyTrueForNonEpsilonTerminals) {
EXPECT_FALSE(st.IsTerminalWthoEol("C"));
}
+TEST(GrammarParserTest, ParsesSimpleGrammar) {
+ GrammarParseResult result = GrammarParser::Parse("A -> a A .\nA -> b .");
+
+ ASSERT_TRUE(result.Ok());
+ const Grammar& gr = result.grammar;
+ EXPECT_EQ(gr.axiom_, "S");
+ std::vector expectedAxiom{{"A", "$"}};
+ std::vector expectedA{{"a", "A"}, {"b"}};
+ EXPECT_EQ(gr.g_.at("S"), expectedAxiom);
+ EXPECT_EQ(gr.g_.at("A"), expectedA);
+ EXPECT_TRUE(gr.st_.IsNonTerminal("A"));
+ EXPECT_TRUE(gr.st_.IsTerminal("a"));
+ EXPECT_TRUE(gr.st_.IsTerminal("b"));
+}
+
+TEST(GrammarParserTest, FirstLeftHandSideIsAxiom) {
+ GrammarParseResult result = GrammarParser::Parse("E -> T .\nT -> n .");
+
+ ASSERT_TRUE(result.Ok());
+ const Grammar& gr = result.grammar;
+ EXPECT_EQ(gr.axiom_, "S");
+ std::vector expectedAxiom{{"E", "$"}};
+ EXPECT_EQ(gr.g_.at("S"), expectedAxiom);
+}
+
+TEST(GrammarParserTest, ParsesMultiCharacterTokens) {
+ GrammarParseResult result =
+ GrammarParser::Parse("function -> f ( Args ) .\nArgs -> id .");
+
+ ASSERT_TRUE(result.Ok());
+ const Grammar& gr = result.grammar;
+ EXPECT_TRUE(gr.st_.IsNonTerminal("function"));
+ EXPECT_TRUE(gr.st_.IsNonTerminal("Args"));
+ EXPECT_TRUE(gr.st_.IsTerminal("f"));
+ EXPECT_TRUE(gr.st_.IsTerminal("("));
+ EXPECT_TRUE(gr.st_.IsTerminal(")"));
+ EXPECT_TRUE(gr.st_.IsTerminal("id"));
+ std::vector expected{{"f", "(", "Args", ")"}};
+ EXPECT_EQ(gr.g_.at("function"), expected);
+}
+
+TEST(GrammarParserTest, LowercaseLeftHandSideIsNonTerminal) {
+ GrammarParseResult result = GrammarParser::Parse("a -> b a | b .");
+
+ ASSERT_TRUE(result.Ok());
+ EXPECT_TRUE(result.grammar.st_.IsNonTerminal("a"));
+ EXPECT_TRUE(result.grammar.st_.IsTerminal("b"));
+}
+
+TEST(GrammarParserTest, EmptyRightHandSideIsEpsilon) {
+ GrammarParseResult result = GrammarParser::Parse("A -> a A .\nA -> .");
+
+ ASSERT_TRUE(result.Ok());
+ const Grammar& gr = result.grammar;
+ std::vector expected{{"a", "A"}, {"EPSILON"}};
+ EXPECT_EQ(gr.g_.at("A"), expected);
+ EXPECT_TRUE(gr.HasEmptyProduction("A"));
+}
+
+TEST(GrammarParserTest, SupportsAlternativesWithPipe) {
+ GrammarParseResult result = GrammarParser::Parse("A -> a A | b | .");
+
+ ASSERT_TRUE(result.Ok());
+ std::vector expected{{"a", "A"}, {"b"}, {"EPSILON"}};
+ EXPECT_EQ(result.grammar.g_.at("A"), expected);
+}
+
+TEST(GrammarParserTest, AcceptsUnicodeArrowAndLooseSpacing) {
+ GrammarParseResult result =
+ GrammarParser::Parse("A→a A. A ->b. B\n->\nc.");
+
+ ASSERT_TRUE(result.Ok());
+ const Grammar& gr = result.grammar;
+ std::vector expectedA{{"a", "A"}, {"b"}};
+ std::vector expectedB{{"c"}};
+ EXPECT_EQ(gr.g_.at("A"), expectedA);
+ EXPECT_EQ(gr.g_.at("B"), expectedB);
+}
+
+TEST(GrammarParserTest, SkipsDuplicatedProductions) {
+ GrammarParseResult result =
+ GrammarParser::Parse("A -> a . A -> a . A -> b .");
+
+ ASSERT_TRUE(result.Ok());
+ std::vector expected{{"a"}, {"b"}};
+ EXPECT_EQ(result.grammar.g_.at("A"), expected);
+}
+
+TEST(GrammarParserTest, AugmentsWithFreshAxiomWhenSIsTaken) {
+ GrammarParseResult result = GrammarParser::Parse("S -> a S | b .");
+
+ ASSERT_TRUE(result.Ok());
+ const Grammar& gr = result.grammar;
+ EXPECT_EQ(gr.axiom_, "S'");
+ std::vector expectedAxiom{{"S", "$"}};
+ EXPECT_EQ(gr.g_.at("S'"), expectedAxiom);
+ EXPECT_TRUE(gr.st_.IsNonTerminal("S'"));
+}
+
+TEST(GrammarParserTest, ReportsMissingArrow) {
+ GrammarParseResult result = GrammarParser::Parse("A a b .");
+
+ ASSERT_FALSE(result.Ok());
+ ASSERT_EQ(result.errors.size(), 1u);
+ EXPECT_EQ(result.errors[0].kind, GrammarParseError::Kind::MissingArrow);
+ EXPECT_EQ(result.errors[0].line, 1);
+}
+
+TEST(GrammarParserTest, ReportsMissingEndDot) {
+ GrammarParseResult result = GrammarParser::Parse("A -> a .\nB -> b");
+
+ ASSERT_FALSE(result.Ok());
+ ASSERT_EQ(result.errors.size(), 1u);
+ EXPECT_EQ(result.errors[0].kind, GrammarParseError::Kind::MissingEndDot);
+ EXPECT_EQ(result.errors[0].line, 2);
+}
+
+TEST(GrammarParserTest, ReportsExtraArrowOnMissingDot) {
+ GrammarParseResult result = GrammarParser::Parse("A -> a\nB -> b .");
+
+ ASSERT_FALSE(result.Ok());
+ ASSERT_EQ(result.errors.size(), 1u);
+ EXPECT_EQ(result.errors[0].kind, GrammarParseError::Kind::ExtraArrow);
+ EXPECT_EQ(result.errors[0].line, 1);
+}
+
+TEST(GrammarParserTest, ReportsMultipleLeftHandSide) {
+ GrammarParseResult result = GrammarParser::Parse("A B -> a .");
+
+ ASSERT_FALSE(result.Ok());
+ ASSERT_EQ(result.errors.size(), 1u);
+ EXPECT_EQ(result.errors[0].kind,
+ GrammarParseError::Kind::MultipleLeftHandSide);
+}
+
+TEST(GrammarParserTest, ReportsEmptyLeftHandSide) {
+ GrammarParseResult result = GrammarParser::Parse("-> a .");
+
+ ASSERT_FALSE(result.Ok());
+ ASSERT_EQ(result.errors.size(), 1u);
+ EXPECT_EQ(result.errors[0].kind,
+ GrammarParseError::Kind::EmptyLeftHandSide);
+}
+
+TEST(GrammarParserTest, ReportsReservedSymbols) {
+ GrammarParseResult result = GrammarParser::Parse("A -> a $ .\nA -> x:y .");
+
+ ASSERT_FALSE(result.Ok());
+ ASSERT_EQ(result.errors.size(), 2u);
+ EXPECT_EQ(result.errors[0].kind, GrammarParseError::Kind::ReservedSymbol);
+ EXPECT_EQ(result.errors[0].detail, "$");
+ EXPECT_EQ(result.errors[1].kind, GrammarParseError::Kind::ReservedSymbol);
+ EXPECT_EQ(result.errors[1].detail, "x:y");
+}
+
+TEST(GrammarParserTest, ReportsEpsilonTokenAsReserved) {
+ GrammarParseResult result = GrammarParser::Parse("A -> EPSILON .");
+
+ ASSERT_FALSE(result.Ok());
+ ASSERT_EQ(result.errors.size(), 1u);
+ EXPECT_EQ(result.errors[0].kind, GrammarParseError::Kind::ReservedSymbol);
+}
+
+TEST(GrammarParserTest, ReportsEmptyGrammar) {
+ GrammarParseResult result = GrammarParser::Parse(" \n\t ");
+
+ ASSERT_FALSE(result.Ok());
+ ASSERT_EQ(result.errors.size(), 1u);
+ EXPECT_EQ(result.errors[0].kind, GrammarParseError::Kind::EmptyGrammar);
+}
+
+TEST(GrammarParserTest, ParsedGrammarWorksWithLL1Parser) {
+ GrammarParseResult result =
+ GrammarParser::Parse("Expr -> Term ExprRest .\n"
+ "ExprRest -> + Term ExprRest | .\n"
+ "Term -> id | num .");
+
+ ASSERT_TRUE(result.Ok());
+ LL1Parser ll1(result.grammar);
+ EXPECT_TRUE(ll1.CreateLL1Table());
+ EXPECT_TRUE(ll1.first_sets_["Expr"].contains("id"));
+ EXPECT_TRUE(ll1.first_sets_["Expr"].contains("num"));
+ EXPECT_TRUE(ll1.follow_sets_["Term"].contains("+"));
+}
+
+TEST(GrammarParserTest, ParsedGrammarWorksWithSLR1Parser) {
+ GrammarParseResult result =
+ GrammarParser::Parse("Expr -> Expr + Term | Term .\n"
+ "Term -> id | ( Expr ) .");
+
+ ASSERT_TRUE(result.Ok());
+ SLR1Parser slr1(result.grammar);
+ EXPECT_TRUE(slr1.MakeParser());
+ EXPECT_FALSE(slr1.states_.empty());
+}
+
+TEST(GrammarParserTest, NonLL1GrammarIsRejectedByTable) {
+ // Left-recursive grammars are valid text but not LL(1).
+ GrammarParseResult result = GrammarParser::Parse("A -> A a | b .");
+
+ ASSERT_TRUE(result.Ok());
+ LL1Parser ll1(result.grammar);
+ EXPECT_FALSE(ll1.CreateLL1Table());
+}
+
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
From f5e6a1acf12ecb6603973b7ca4f6d7ebbcb153ea Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Thu, 11 Jun 2026 22:48:10 +0200
Subject: [PATCH 05/10] feat: add custom grammar check
---
src/gui/mainwindow.cpp | 31 ++++++++++++++++++++++++++++---
src/gui/mainwindow.h | 8 ++++++++
2 files changed, 36 insertions(+), 3 deletions(-)
diff --git a/src/gui/mainwindow.cpp b/src/gui/mainwindow.cpp
index 8e77169a..3032d82d 100644
--- a/src/gui/mainwindow.cpp
+++ b/src/gui/mainwindow.cpp
@@ -17,6 +17,7 @@
*/
#include "mainwindow.h"
+#include "grammareditordialog.h"
#include "tutorialmanager.h"
#include "ui_mainwindow.h"
#include
@@ -326,9 +327,11 @@ void MainWindow::setNavigationEnabled(bool enabled) {
ui->pushButton->setDisabled(!enabled);
ui->pushButton_2->setDisabled(!enabled);
ui->tutorial->setDisabled(!enabled);
- ui->lv1Button->setDisabled(!enabled);
- ui->lv2Button->setDisabled(!enabled);
- ui->lv3Button->setDisabled(!enabled);
+ const bool levelsEnabled = enabled && !ui->customGrammarCheck->isChecked();
+ ui->lv1Button->setEnabled(levelsEnabled);
+ ui->lv2Button->setEnabled(levelsEnabled);
+ ui->lv3Button->setEnabled(levelsEnabled);
+ ui->customGrammarCheck->setEnabled(enabled);
}
void MainWindow::showHomePage() {
@@ -470,12 +473,34 @@ void MainWindow::handleTutorFinished(int cntRight, int cntWrong) {
saveSettings();
}
+void MainWindow::on_customGrammarCheck_toggled(bool checked) {
+ ui->lv1Button->setEnabled(!checked);
+ ui->lv2Button->setEnabled(!checked);
+ ui->lv3Button->setEnabled(!checked);
+}
+
void MainWindow::on_pushButton_clicked() {
+ if (ui->customGrammarCheck->isChecked()) {
+ GrammarEditorDialog dialog(GrammarEditorDialog::Mode::LL1, this);
+ if (dialog.exec() != QDialog::Accepted) {
+ return;
+ }
+ startLLTutor(dialog.grammar(), nullptr);
+ return;
+ }
Grammar grammar = factory.GenLL1Grammar(level);
startLLTutor(grammar, nullptr);
}
void MainWindow::on_pushButton_2_clicked() {
+ if (ui->customGrammarCheck->isChecked()) {
+ GrammarEditorDialog dialog(GrammarEditorDialog::Mode::SLR1, this);
+ if (dialog.exec() != QDialog::Accepted) {
+ return;
+ }
+ startSLRTutor(dialog.grammar(), nullptr);
+ return;
+ }
Grammar grammar = factory.GenSLR1Grammar(level);
startSLRTutor(grammar, nullptr);
}
diff --git a/src/gui/mainwindow.h b/src/gui/mainwindow.h
index eecb3cd7..0b0109bb 100644
--- a/src/gui/mainwindow.h
+++ b/src/gui/mainwindow.h
@@ -106,6 +106,14 @@ class MainWindow : public QMainWindow {
void on_lv2Button_clicked(bool checked);
void on_lv3Button_clicked(bool checked);
+ /**
+ * @brief Toggles between random grammars and user-written grammars.
+ *
+ * When checked, difficulty levels no longer apply and starting an
+ * exercise opens the grammar editor first.
+ */
+ void on_customGrammarCheck_toggled(bool checked);
+
/**
* @brief Opens the LL(1) exercise dialog.
*/
From ba78349cc2b0c4c9b5fed8e870be6c5e0418947b Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Thu, 11 Jun 2026 22:48:58 +0200
Subject: [PATCH 06/10] feat: change tokenize symbol seq
---
src/gui/slrtutorwindow.cpp | 134 ++++++++++++++++++-------------------
1 file changed, 67 insertions(+), 67 deletions(-)
diff --git a/src/gui/slrtutorwindow.cpp b/src/gui/slrtutorwindow.cpp
index f976571f..233ef637 100644
--- a/src/gui/slrtutorwindow.cpp
+++ b/src/gui/slrtutorwindow.cpp
@@ -26,7 +26,7 @@
#include
#include
#include
-#include
+#include
namespace {
// Packs a (row, col) pair into a single 64-bit key.
@@ -144,6 +144,38 @@ ParsedIdCounts ParseIdCountList(const QString& input) {
return parsed;
}
+// Tokenizes one side of an item or rule into grammar symbols. Space-separated
+// known symbols win, so multi-character tokens stay unambiguous; otherwise the
+// text falls back to the symbol-table greedy split used for compact answers
+// like "aA". Returns an empty vector when the text cannot be tokenized.
+std::vector TokenizeSymbolSequence(Grammar& grammar,
+ const QString& text) {
+ static const QRegularExpression kBlanks("\\s+");
+ const QString trimmed = text.trimmed();
+ if (trimmed.isEmpty()) {
+ return {};
+ }
+
+ const QStringList spacedTokens = trimmed.split(kBlanks, Qt::SkipEmptyParts);
+ const bool allKnown =
+ std::all_of(spacedTokens.cbegin(), spacedTokens.cend(),
+ [&grammar](const QString& token) {
+ return grammar.st_.In(token.toStdString());
+ });
+ if (allKnown) {
+ std::vector symbols;
+ symbols.reserve(spacedTokens.size());
+ for (const QString& token : spacedTokens) {
+ symbols.push_back(token.toStdString());
+ }
+ return symbols;
+ }
+
+ QString compact = trimmed;
+ compact.remove(kBlanks);
+ return grammar.Split(compact.toStdString());
+}
+
bool NormalizeSlrCell(const QString& cell, QString* normalized) {
const QString trimmed = cell.trimmed();
if (trimmed.isEmpty()) {
@@ -219,11 +251,12 @@ SLRTutorWindow::SLRTutorWindow(const Grammar& g, TutorialManager* tm,
// ====== Grammar Formatting =================================
sortedNonTerminals =
stdUnorderedSetToQSet(slr1.gr_.st_.non_terminals_).values();
+ const QString axiom = QString::fromStdString(grammar.axiom_);
std::ranges::sort(sortedNonTerminals,
- [](const QString& a, const QString& b) {
- if (a == "S")
+ [&axiom](const QString& a, const QString& b) {
+ if (a == axiom)
return true;
- if (b == "S")
+ if (b == axiom)
return false;
return a < b;
});
@@ -578,11 +611,11 @@ void SLRTutorWindow::showTable() {
colHeaders << QString::fromStdString(symbol);
}
std::sort(colHeaders.begin(), colHeaders.end(),
- [](const QString& a, const QString& b) {
- auto rank = [](const QString& s) -> int {
+ [this](const QString& a, const QString& b) {
+ auto rank = [this](const QString& s) -> int {
if (s == "$")
return 1;
- if (!s.isEmpty() && s[0].isLower())
+ if (slr1.gr_.st_.IsTerminalWthoEol(s.toStdString()))
return 0;
return 2;
};
@@ -3058,51 +3091,37 @@ SLRTutorWindow::ingestUserItems(const QString& userResponse) {
QStringList lines = userResponse.split('\n', Qt::SkipEmptyParts);
for (const QString& line : std::as_const(lines)) {
- QString normalized = line.trimmed();
- std::string token = normalized.toStdString();
- size_t arrowpos = token.find("->");
- if (arrowpos == std::string::npos) {
+ QString normalized = line.trimmed();
+ normalized.replace(QStringLiteral("·"), QStringLiteral("."));
+
+ const qsizetype arrowpos = normalized.indexOf(QStringLiteral("->"));
+ if (arrowpos < 0) {
return {};
}
- std::string antecedent = token.substr(0, arrowpos);
- std::string consequent = token.substr(arrowpos + 2);
-
- auto trim = [](std::string& s) {
- size_t start = s.find_first_not_of(" \t");
- size_t end = s.find_last_not_of(" \t");
- if (start == std::string::npos) {
- s.clear();
- } else {
- s = s.substr(start, end - start + 1);
- }
- };
-
- trim(antecedent);
- trim(consequent);
-
- consequent.erase(
- std::remove_if(consequent.begin(), consequent.end(),
- [](char c) { return c == ' ' || c == '\t'; }),
- consequent.end());
+ const std::string antecedent =
+ normalized.left(arrowpos).trimmed().toStdString();
+ const QString consequent = normalized.mid(arrowpos + 2);
- size_t dotpos = consequent.find('.');
- if (dotpos == std::string::npos) {
+ const qsizetype dotpos = consequent.indexOf('.');
+ if (dotpos < 0) {
return {};
}
- std::string before_dot = consequent.substr(0, dotpos);
- std::string after_dot = consequent.substr(dotpos + 1);
+ const QString before_dot = consequent.left(dotpos).trimmed();
+ const QString after_dot = consequent.mid(dotpos + 1).trimmed();
- std::vector splitted_before_dot{grammar.Split(before_dot)};
- std::vector splitted_after_dot{grammar.Split(after_dot)};
+ std::vector splitted_before_dot{
+ TokenizeSymbolSequence(grammar, before_dot)};
+ std::vector splitted_after_dot{
+ TokenizeSymbolSequence(grammar, after_dot)};
- if (!before_dot.empty() && splitted_before_dot.empty()) {
+ if (!before_dot.isEmpty() && splitted_before_dot.empty()) {
return {};
}
- if (!after_dot.empty() && splitted_after_dot.empty()) {
+ if (!after_dot.isEmpty() && splitted_after_dot.empty()) {
return {};
}
- if (before_dot.empty() && after_dot.empty()) {
+ if (before_dot.isEmpty() && after_dot.isEmpty()) {
splitted_before_dot = {grammar.st_.EPSILON_};
}
@@ -3119,41 +3138,22 @@ SLRTutorWindow::ingestUserItems(const QString& userResponse) {
std::vector>>
SLRTutorWindow::ingestUserRules(const QString& userResponse) {
- std::stringstream ss(userResponse.toStdString());
- std::string token;
std::vector>> rules;
QStringList lines = userResponse.split('\n', Qt::SkipEmptyParts);
for (const QString& line : std::as_const(lines)) {
- QString normalized = line.trimmed();
- std::string token = normalized.toStdString();
+ const QString normalized = line.trimmed();
- size_t arrowpos = token.find("->");
- if (arrowpos == std::string::npos) {
+ const qsizetype arrowpos = normalized.indexOf(QStringLiteral("->"));
+ if (arrowpos < 0) {
return {};
}
- std::string antecedent = token.substr(0, arrowpos);
- std::string consequent = token.substr(arrowpos + 2);
-
- auto trim = [](std::string& s) {
- size_t start = s.find_first_not_of(" \t");
- size_t end = s.find_last_not_of(" \t");
- if (start == std::string::npos) {
- s.clear();
- } else {
- s = s.substr(start, end - start + 1);
- }
- };
-
- trim(antecedent);
- trim(consequent);
-
- consequent.erase(
- std::remove_if(consequent.begin(), consequent.end(),
- [](char c) { return c == ' ' || c == '\t'; }),
- consequent.end());
+ const std::string antecedent =
+ normalized.left(arrowpos).trimmed().toStdString();
+ const QString consequent = normalized.mid(arrowpos + 2);
- std::vector splitted{grammar.Split(consequent)};
+ std::vector splitted{
+ TokenizeSymbolSequence(grammar, consequent)};
rules.emplace_back(antecedent, splitted);
}
From 8894afb734e26337d407ca67849cac630e36cd94 Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Thu, 11 Jun 2026 22:49:13 +0200
Subject: [PATCH 07/10] feat: change ui
---
src/gui/mainwindow.ui | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/src/gui/mainwindow.ui b/src/gui/mainwindow.ui
index 01fca6ad..c106b886 100644
--- a/src/gui/mainwindow.ui
+++ b/src/gui/mainwindow.ui
@@ -326,6 +326,19 @@
+ -
+
+
+ PointingHandCursor
+
+
+ Usar mi propia gramática
+
+
+ Escribe tu propia gramática antes de empezar el ejercicio, en lugar de usar una aleatoria.
+
+
+
-
From cdfb4e89d74e2dbb496f3250d5513cbc9785c5cc Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Thu, 11 Jun 2026 22:49:27 +0200
Subject: [PATCH 08/10] test: add ui tests
---
tests/tests.pro | 4 +
tests/tutor/main_window_test.cpp | 286 +++++++++++++++++++++++++++++++
tests/tutor/tutor_window_test.h | 7 +
3 files changed, 297 insertions(+)
diff --git a/tests/tests.pro b/tests/tests.pro
index af540649..d14a11a1 100644
--- a/tests/tests.pro
+++ b/tests/tests.pro
@@ -25,6 +25,7 @@ DESTDIR = $$OUT_PWD/.bin
SOURCES += \
../src/backend/grammar.cpp \
../src/backend/grammar_factory.cpp \
+ ../src/backend/grammar_parser.cpp \
../src/backend/ll1_parser.cpp \
../src/backend/lr0_item.cpp \
../src/backend/slr1_parser.cpp \
@@ -32,6 +33,7 @@ SOURCES += \
../src/widgets/customtextedit.cpp \
../src/widgets/grammarview.cpp \
../src/widgets/tutorialmanager.cpp \
+ ../src/gui/grammareditordialog.cpp \
../src/gui/lltabledialog.cpp \
../src/gui/lltutorwindow.cpp \
../src/gui/mainwindow.cpp \
@@ -46,6 +48,7 @@ SOURCES += \
HEADERS += \
../src/backend/grammar.hpp \
../src/backend/grammar_factory.hpp \
+ ../src/backend/grammar_parser.hpp \
../src/backend/ll1_parser.hpp \
../src/backend/lr0_item.hpp \
../src/backend/slr1_parser.hpp \
@@ -54,6 +57,7 @@ HEADERS += \
../src/widgets/customtextedit.h \
../src/widgets/grammarview.h \
../src/widgets/tutorialmanager.h \
+ ../src/gui/grammareditordialog.h \
../src/gui/lltabledialog.h \
../src/gui/lltutorwindow.h \
../src/gui/mainwindow.h \
diff --git a/tests/tutor/main_window_test.cpp b/tests/tutor/main_window_test.cpp
index f78587a4..58da7085 100644
--- a/tests/tutor/main_window_test.cpp
+++ b/tests/tutor/main_window_test.cpp
@@ -1,19 +1,24 @@
#include "tutor_window_test.h"
+#include "grammareditordialog.h"
#include "mainwindow.h"
#include "lltutorwindow.h"
#include "qt_modal_test_utils.h"
#include "slrtutorwindow.h"
+#include
#include
#include
#include
+#include
#include
#include
+#include
#include
#include
#include
#include
+#include
namespace {
@@ -469,3 +474,284 @@ void TutorWindowTest::mainStatePersistenceAcrossRestart() {
QCOMPARE(window.findChild("labelScore")->text(), QString("Puntos: 7"));
QCOMPARE(window.findChild("progressBarNivel")->value(), 23);
}
+
+namespace {
+
+void scheduleGrammarEditorSubmission(const QString& grammarText) {
+ QtModalTestUtils::scheduleUntilHandled([grammarText]() {
+ auto* dialog =
+ QtModalTestUtils::findVisibleTopLevelWidget();
+ if (dialog == nullptr) {
+ return false;
+ }
+
+ dialog->setGrammarTextForTest(grammarText);
+ if (!dialog->isGrammarValidForTest()) {
+ return false;
+ }
+
+ auto* start =
+ dialog->findChild("grammarEditorStartButton");
+ if (start == nullptr || !start->isEnabled()) {
+ return false;
+ }
+ QTest::mouseClick(start, Qt::LeftButton);
+ return true;
+ });
+}
+
+void scheduleGrammarEditorCancel() {
+ QtModalTestUtils::scheduleUntilHandled([]() {
+ auto* dialog =
+ QtModalTestUtils::findVisibleTopLevelWidget();
+ if (dialog == nullptr) {
+ return false;
+ }
+
+ auto* cancel =
+ dialog->findChild("grammarEditorCancelButton");
+ if (cancel == nullptr) {
+ return false;
+ }
+ QTest::mouseClick(cancel, Qt::LeftButton);
+ return true;
+ });
+}
+
+bool anyLabelContains(QWidget* root, const QString& needle) {
+ const QList labels = root->findChildren();
+ return std::any_of(labels.cbegin(), labels.cend(),
+ [&needle](const QLabel* label) {
+ return label->text().contains(needle);
+ });
+}
+
+} // namespace
+
+// -----------------------------------------------------------------------------
+// Case: MAIN-TC-13
+// Summary:
+// Verifies that enabling "use my own grammar" disables difficulty levels and
+// that disabling it restores them.
+//
+// Situation:
+// Main window freshly opened on the home screen.
+//
+// Action:
+// The test toggles the custom grammar checkbox on and off.
+//
+// Expected:
+// Level radio buttons are disabled while the checkbox is checked and enabled
+// again when unchecked.
+// -----------------------------------------------------------------------------
+void TutorWindowTest::mainCustomGrammarToggleDisablesLevels() {
+ clearTestAppSettings();
+
+ MainWindow window;
+ window.show();
+
+ auto* check = window.findChild("customGrammarCheck");
+ QVERIFY(check != nullptr);
+ QVERIFY(check->isEnabled());
+ QVERIFY(!check->isChecked());
+
+ check->setChecked(true);
+ QVERIFY(!window.findChild("lv1Button")->isEnabled());
+ QVERIFY(!window.findChild("lv2Button")->isEnabled());
+ QVERIFY(!window.findChild("lv3Button")->isEnabled());
+
+ check->setChecked(false);
+ QVERIFY(window.findChild("lv1Button")->isEnabled());
+ QVERIFY(window.findChild("lv2Button")->isEnabled());
+ QVERIFY(window.findChild("lv3Button")->isEnabled());
+}
+
+// -----------------------------------------------------------------------------
+// Case: MAIN-TC-14
+// Summary:
+// Starts an LL(1) exercise with a user-written grammar that uses
+// multi-character tokens.
+//
+// Situation:
+// Custom grammar mode enabled on the home screen.
+//
+// Action:
+// The test clicks the LL(1) button, writes an LL(1) grammar in the editor
+// dialog and presses the start button.
+//
+// Expected:
+// The LL(1) tutor opens showing the user's symbols and the grammar text is
+// persisted for the next session.
+// -----------------------------------------------------------------------------
+void TutorWindowTest::mainCustomGrammarLlFlowStartsTutorWithUserGrammar() {
+ clearTestAppSettings();
+
+ MainWindow window;
+ window.show();
+ window.findChild("customGrammarCheck")->setChecked(true);
+
+ const QString grammarText =
+ QStringLiteral("Expr -> id Resto .\nResto -> + id Resto | .");
+ scheduleGrammarEditorSubmission(grammarText);
+ QTest::mouseClick(window.findChild("pushButton"),
+ Qt::LeftButton);
+
+ LLTutorWindow* tutor = nullptr;
+ QTRY_VERIFY((tutor = window.findChild()) != nullptr);
+ QVERIFY(anyLabelContains(tutor, QStringLiteral("Resto")));
+
+ QSettings settings = testAppSettings();
+ QCOMPARE(settings.value("userGrammar/lastText").toString(), grammarText);
+}
+
+// -----------------------------------------------------------------------------
+// Case: MAIN-TC-15
+// Summary:
+// Starts an SLR(1) exercise with a user-written left-recursive grammar.
+//
+// Situation:
+// Custom grammar mode enabled on the home screen.
+//
+// Action:
+// The test clicks the SLR(1) button and submits a left-recursive grammar
+// (valid SLR(1), invalid LL(1)) through the editor dialog.
+//
+// Expected:
+// The SLR(1) tutor opens showing the user's symbols.
+// -----------------------------------------------------------------------------
+void TutorWindowTest::mainCustomGrammarSlrFlowStartsTutorWithUserGrammar() {
+ clearTestAppSettings();
+
+ MainWindow window;
+ window.show();
+ window.findChild("customGrammarCheck")->setChecked(true);
+
+ scheduleGrammarEditorSubmission(QStringLiteral("Expr -> Expr + id | id ."));
+ QTest::mouseClick(window.findChild("pushButton_2"),
+ Qt::LeftButton);
+
+ SLRTutorWindow* tutor = nullptr;
+ QTRY_VERIFY((tutor = window.findChild()) != nullptr);
+ QVERIFY(anyLabelContains(tutor, QStringLiteral("Expr")));
+}
+
+// -----------------------------------------------------------------------------
+// Case: MAIN-TC-16
+// Summary:
+// Exercises the grammar editor validation: format errors, reserved symbols,
+// the LL(1) check, and a final valid grammar.
+//
+// Situation:
+// Grammar editor dialog opened directly in LL(1) mode.
+//
+// Action:
+// The test writes several invalid grammars followed by a valid one.
+//
+// Expected:
+// The start button stays disabled and an error message is shown until the
+// grammar becomes valid.
+// -----------------------------------------------------------------------------
+void TutorWindowTest::mainCustomGrammarEditorRejectsInvalidAndNonLl1Grammars() {
+ clearTestAppSettings();
+
+ GrammarEditorDialog dialog(GrammarEditorDialog::Mode::LL1);
+ dialog.show();
+
+ auto* start = dialog.findChild("grammarEditorStartButton");
+ auto* status = dialog.findChild("grammarEditorStatus");
+ QVERIFY(start != nullptr);
+ QVERIFY(status != nullptr);
+
+ dialog.setGrammarTextForTest(QStringLiteral("A -> a"));
+ QVERIFY(!dialog.isGrammarValidForTest());
+ QVERIFY(!start->isEnabled());
+ QVERIFY(!status->text().isEmpty());
+
+ dialog.setGrammarTextForTest(QStringLiteral("A B -> a ."));
+ QVERIFY(!dialog.isGrammarValidForTest());
+
+ dialog.setGrammarTextForTest(QStringLiteral("A -> a $ ."));
+ QVERIFY(!dialog.isGrammarValidForTest());
+
+ dialog.setGrammarTextForTest(QStringLiteral("A -> a\nB -> b ."));
+ QVERIFY(!dialog.isGrammarValidForTest());
+
+ // Left recursion is fine for SLR(1) but must be rejected in LL(1) mode.
+ dialog.setGrammarTextForTest(QStringLiteral("Expr -> Expr + id | id ."));
+ QVERIFY(!dialog.isGrammarValidForTest());
+
+ // Non-productive grammars never derive a terminal string.
+ dialog.setGrammarTextForTest(QStringLiteral("A -> a B .\nB -> B b ."));
+ QVERIFY(!dialog.isGrammarValidForTest());
+
+ dialog.setGrammarTextForTest(QStringLiteral("A -> a A | b ."));
+ QVERIFY(dialog.isGrammarValidForTest());
+ QVERIFY(start->isEnabled());
+}
+
+// -----------------------------------------------------------------------------
+// Case: MAIN-TC-17
+// Summary:
+// Cancels the grammar editor and verifies no tutor is started.
+//
+// Situation:
+// Custom grammar mode enabled on the home screen.
+//
+// Action:
+// The test clicks the LL(1) button and dismisses the editor dialog.
+//
+// Expected:
+// The application stays on the home page with no tutor created.
+// -----------------------------------------------------------------------------
+void TutorWindowTest::mainCustomGrammarEditorCancelKeepsHomePage() {
+ clearTestAppSettings();
+
+ MainWindow window;
+ window.show();
+ window.findChild("customGrammarCheck")->setChecked(true);
+
+ scheduleGrammarEditorCancel();
+ QTest::mouseClick(window.findChild("pushButton"),
+ Qt::LeftButton);
+
+ QTest::qWait(50);
+ QVERIFY(window.findChild() == nullptr);
+ QVERIFY(window.findChild("pushButton")->isEnabled());
+}
+
+// -----------------------------------------------------------------------------
+// Case: MAIN-TC-18
+// Summary:
+// Verifies that the last accepted grammar is restored when the editor is
+// reopened.
+//
+// Situation:
+// A grammar was accepted in a previous editor session.
+//
+// Action:
+// The test accepts a grammar in one dialog instance and opens a new one.
+//
+// Expected:
+// The new editor starts pre-filled with the persisted grammar and is
+// immediately valid.
+// -----------------------------------------------------------------------------
+void TutorWindowTest::mainCustomGrammarEditorRestoresLastGrammar() {
+ clearTestAppSettings();
+
+ const QString grammarText = QStringLiteral("A -> a A | b .");
+ {
+ GrammarEditorDialog dialog(GrammarEditorDialog::Mode::LL1);
+ dialog.show();
+ dialog.setGrammarTextForTest(grammarText);
+ QVERIFY(dialog.isGrammarValidForTest());
+ QTest::mouseClick(
+ dialog.findChild("grammarEditorStartButton"),
+ Qt::LeftButton);
+ }
+
+ GrammarEditorDialog reopened(GrammarEditorDialog::Mode::LL1);
+ QCOMPARE(reopened.findChild("grammarEditorInput")
+ ->toPlainText(),
+ grammarText);
+ QVERIFY(reopened.isGrammarValidForTest());
+}
diff --git a/tests/tutor/tutor_window_test.h b/tests/tutor/tutor_window_test.h
index bc5f2a2c..a8269915 100644
--- a/tests/tutor/tutor_window_test.h
+++ b/tests/tutor/tutor_window_test.h
@@ -54,4 +54,11 @@ class TutorWindowTest : public QObject {
void mainTutorialFlowCompletesAndReenablesControls();
void mainGamificationPersistsAcrossRestart();
void mainStatePersistenceAcrossRestart();
+
+ void mainCustomGrammarToggleDisablesLevels();
+ void mainCustomGrammarLlFlowStartsTutorWithUserGrammar();
+ void mainCustomGrammarSlrFlowStartsTutorWithUserGrammar();
+ void mainCustomGrammarEditorRejectsInvalidAndNonLl1Grammars();
+ void mainCustomGrammarEditorCancelKeepsHomePage();
+ void mainCustomGrammarEditorRestoresLastGrammar();
};
From d8e17e0372df3f93cad08fd841f640740d3d5dde Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Thu, 11 Jun 2026 22:49:37 +0200
Subject: [PATCH 09/10] feat: styles
---
resources.qrc | 1 +
resources/styles/app.qss | 126 +++++++++++++++++++++++++++++++++++++++
2 files changed, 127 insertions(+)
diff --git a/resources.qrc b/resources.qrc
index 641551d3..6c4107aa 100644
--- a/resources.qrc
+++ b/resources.qrc
@@ -1,5 +1,6 @@
+ resources/check.svg
resources/send.svg
resources/syntaxtutor.png
resources/NotoSans-Bold.ttf
diff --git a/resources/styles/app.qss b/resources/styles/app.qss
index 1181458b..16f92c62 100644
--- a/resources/styles/app.qss
+++ b/resources/styles/app.qss
@@ -618,3 +618,129 @@ QDialog[guidedDialog="slr"] QPushButton#slrWizardNextButton:disabled {
color: #6F7A7E;
border: 1px solid #353D40;
}
+
+QDialog[grammarEditor="true"] {
+ background-color: #1F1F1F;
+ border: none;
+}
+
+QDialog[grammarEditor="true"] QLabel#grammarEditorEyebrow {
+ color: #11B3BC;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+}
+
+QDialog[grammarEditor="true"] QLabel#grammarEditorTitle {
+ color: #F4F7F8;
+ font-size: 28px;
+ font-weight: 700;
+}
+
+QDialog[grammarEditor="true"] QLabel#grammarEditorSubtitle {
+ color: #9AA5A8;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+QDialog[grammarEditor="true"] QPlainTextEdit#grammarEditorInput {
+ background-color: #2B3133;
+ color: #F1F4F5;
+ border: 1px solid rgba(255, 255, 255, 0.10);
+ border-radius: 12px;
+ padding: 12px 14px;
+ selection-background-color: #35515A;
+}
+
+QDialog[grammarEditor="true"] QPlainTextEdit#grammarEditorInput:focus {
+ border: 1px solid #11B3BC;
+}
+
+QDialog[grammarEditor="true"] QFrame#grammarEditorSummary {
+ background-color: #212526;
+ border: 1px solid #303638;
+ border-radius: 12px;
+}
+
+QDialog[grammarEditor="true"] QLabel#grammarEditorSummaryCaption {
+ color: #9FCFD2;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+}
+
+QDialog[grammarEditor="true"] QLabel#grammarEditorSummaryValue {
+ color: #E8ECEE;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+QDialog[grammarEditor="true"] QLabel#grammarEditorStatus {
+ color: #AEB9BC;
+ font-size: 13px;
+ padding: 8px 12px;
+ border-radius: 10px;
+ background-color: rgba(255, 255, 255, 0.03);
+}
+
+QDialog[grammarEditor="true"] QLabel#grammarEditorStatus[state="error"] {
+ color: #F1B0AE;
+ background-color: rgba(217, 83, 79, 0.12);
+}
+
+QDialog[grammarEditor="true"] QLabel#grammarEditorStatus[state="ok"] {
+ color: #9FE3E7;
+ background-color: rgba(17, 179, 188, 0.12);
+}
+
+QDialog[grammarEditor="true"] QPushButton#grammarEditorCancelButton {
+ background: transparent;
+ color: #AAB5B8;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ padding: 10px 16px;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+QDialog[grammarEditor="true"] QPushButton#grammarEditorCancelButton:hover {
+ background-color: rgba(255, 255, 255, 0.03);
+ color: #FFFFFF;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+}
+
+QDialog[grammarEditor="true"] QPushButton#grammarEditorStartButton {
+ min-width: 170px;
+ padding: 11px 18px;
+ border-radius: 10px;
+}
+
+QDialog[grammarEditor="true"] QPushButton#grammarEditorStartButton:disabled {
+ background-color: #2A2E30;
+ color: #6F7A7E;
+ border: 1px solid #353D40;
+}
+
+QCheckBox#customGrammarCheck {
+ color: #C9D0D2;
+ font-size: 13px;
+ spacing: 8px;
+}
+
+QCheckBox#customGrammarCheck::indicator {
+ width: 16px;
+ height: 16px;
+ border-radius: 4px;
+ border: 1px solid #3A4144;
+ background-color: #2A2E30;
+}
+
+QCheckBox#customGrammarCheck::indicator:hover {
+ border: 1px solid #11B3BC;
+}
+
+QCheckBox#customGrammarCheck::indicator:checked {
+ background-color: #11B3BC;
+ border: 1px solid #11B3BC;
+ image: url(:/resources/check.svg);
+}
From d6d6e68c06667d8b1933123d97944aa2d77a8376 Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Thu, 11 Jun 2026 22:49:47 +0200
Subject: [PATCH 10/10] chore: add resources
---
resources/check.svg | 3 +++
1 file changed, 3 insertions(+)
create mode 100644 resources/check.svg
diff --git a/resources/check.svg b/resources/check.svg
new file mode 100644
index 00000000..16649572
--- /dev/null
+++ b/resources/check.svg
@@ -0,0 +1,3 @@
+