001package gudusoft.gsqlparser.pp.score;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.TCustomSqlStatement;
005import gudusoft.gsqlparser.TGSqlParser;
006import gudusoft.gsqlparser.pp2.token.TokenEquivalence;
007
008import java.util.ArrayList;
009import java.util.Arrays;
010import java.util.LinkedHashMap;
011import java.util.List;
012import java.util.Locale;
013import java.util.Map;
014
015/**
016 * PrettyScore: scores the readability of an <b>already formatted</b> SQL text
017 * against a style {@link ScoreProfile} and explains every deduction.
018 *
019 * <pre>
020 *   ScoreReport r = new PrettyScore().score(formattedSql, EDbVendor.dbvmssql, ScoreProfile.defaults(), originalSql, null);
021 *   r.getScore();       // 0-100
022 *   r.getAttainable();  // what the formatter could reach on this input
023 *   r.getRatio();       // score / attainable — the closed-loop target
024 *   r.toJson();         // published contract, version "prettyscore/0.1"
025 * </pre>
026 *
027 * <p><b>Read this before quoting a number.</b> While the profile's
028 * calibration is {@code heuristic-v0} the weights are engineering guesses:
029 * the score is reliable only as a relative measure within one profile on one
030 * corpus (before/after a formatter change). It is not an absolute readability
031 * verdict and must not be presented as one. A script that never wraps (one
032 * over-wide line holding most tokens) is charged on width, density and indent
033 * even when local agreement would otherwise look perfect.
034 *
035 * <p>Syntax errors are fine: the lexical facts come from the pp2 pipeline,
036 * which never needs a successful parse; only the AST depth is lost, in which
037 * case the indent dimension's weight is halved and a note is attached.
038 *
039 * <p>Thread-safety: the only configuration is {@link #setAstEnabled}, read
040 * once at the start of every {@code score} call; instances may be shared.
041 */
042public final class PrettyScore {
043
044    private volatile boolean astEnabled = true;
045
046    /**
047     * Whether a second GSP parse is run to stamp AST depth (default true).
048     * Disabling it makes scoring O(tokens) with no parser cost, at the price
049     * of the indent dimension's weight being halved as if the text had not
050     * parsed. Useful for very large scripts or pure lexical regression runs.
051     */
052    public PrettyScore setAstEnabled(boolean enabled) {
053        this.astEnabled = enabled;
054        return this;
055    }
056
057    public boolean isAstEnabled() {
058        return astEnabled;
059    }
060
061    /** Score {@code sql} with the default profile and no fidelity gate. */
062    public ScoreReport score(String sql, EDbVendor vendor) {
063        return score(sql, vendor, ScoreProfile.defaults(), null, null);
064    }
065
066    /**
067     * @param sql      the formatted SQL; must not be null
068     * @param vendor   dialect; must not be null
069     * @param profile  style profile; null selects {@code isf-default}
070     * @param original the un-formatted input; when given the fidelity gate
071     *                 (token / comment / AST preservation) and the comments
072     *                 dimension are evaluated
073     * @param options  the formatter options the caller claims produced
074     *                 {@code sql} ({@code GFmtOpt} field names); when given,
075     *                 contradictions surface as {@code OPTION_NOT_HONORED}
076     */
077    public ScoreReport score(String sql, EDbVendor vendor, ScoreProfile profile, String original,
078                             Map<String, Object> options) {
079        if (sql == null) throw new NullPointerException("sql");
080        if (vendor == null) throw new NullPointerException("vendor");
081        if (profile == null) profile = ScoreProfile.defaults();
082        final boolean ast = astEnabled; // snapshot: a concurrent setter cannot split one scoring run
083
084        FactsBuilder builder = new FactsBuilder(profile);
085        Facts facts = builder.build(sql, vendor, ast);
086        Facts origFacts = original == null ? null : builder.build(original, vendor, false);
087        Geometry geo = new Geometry(facts, profile);
088        RuleContext ctx = new RuleContext(facts, profile, geo, origFacts, options);
089
090        List<Rule> rules = Arrays.<Rule>asList(new SpineRule(), new IndentRule(), new WidthRule(), new DensityRule(),
091            new AlignmentRule(), new ConsistencyRule(), new GroupingRule(), new CommentRule(), new OptionRule());
092        for (Rule r : rules) {
093            r.apply(ctx);
094        }
095        Explainer.depthMetrics(facts, ctx.metrics);
096
097        // ---- gate
098        List<ScoreReport.GateCheck> checks = new ArrayList<ScoreReport.GateCheck>();
099        boolean gateEvaluated = original != null;
100        boolean gatePassed = true;
101        if (gateEvaluated) {
102            boolean tokens = TokenEquivalence.equalsModuloFormatting(original, sql, null, vendor, true);
103            checks.add(new ScoreReport.GateCheck("TOKEN_PRESERVED", tokens,
104                tokens ? "" : "solid token sequence differs from the original", null));
105            Map<String, Object> extra = new LinkedHashMap<String, Object>();
106            extra.put("input", origFacts.getCommentCount());
107            extra.put("output", facts.getCommentCount());
108            boolean comments = origFacts.getCommentCount() == facts.getCommentCount();
109            checks.add(new ScoreReport.GateCheck("COMMENTS_PRESERVED", comments,
110                comments ? "" : "comment count changed", extra));
111            if (tokens) {
112                int recased = recasedIdentifiers(origFacts, facts);
113                Map<String, Object> idExtra = new LinkedHashMap<String, Object>();
114                idExtra.put("changed", recased);
115                boolean idOk = recased == 0 || !profile.isStrictIdentifierCase();
116                checks.add(new ScoreReport.GateCheck("IDENTIFIER_CASE", idOk,
117                    recased == 0 ? "" : recased + " unquoted identifier(s) recased" + (idOk ? " (informational; profile.strictIdentifierCase=false)" : ""), idExtra));
118            }
119            if (ast) {
120                String astDetail = astEquivalent(original, sql, vendor);
121                if (astDetail != null) {
122                    checks.add(new ScoreReport.GateCheck("AST_EQUIVALENT", astDetail.isEmpty(), astDetail, null));
123                }
124            } else {
125                checks.add(new ScoreReport.GateCheck("AST_EQUIVALENT", true, "not evaluated (ast disabled)", null));
126            }
127            for (ScoreReport.GateCheck c : checks) if (!c.isOk()) gatePassed = false;
128        }
129        Map<String, Object> parseExtra = new LinkedHashMap<String, Object>();
130        parseExtra.put("parse_ok", facts.isParseOk());
131        parseExtra.put("error_regions", facts.getErrorRegions());
132        parseExtra.put("unclosed_blocks", facts.getUnclosedBlocks());
133        checks.add(new ScoreReport.GateCheck("PARSE_STATUS", true,
134            facts.isParseOk() ? "parsed" : "not parsed; pp2-tolerant facts only", parseExtra));
135
136        Explainer.Result agg = Explainer.aggregate(ctx.findings, profile, facts, gatePassed, ctx.metrics);
137
138        ctx.metrics.put("statements", facts.getStatementCount());
139        ctx.metrics.put("comments", facts.getCommentCount());
140        if (!ast) ctx.notes.add("ast disabled by caller: parse status not evaluated, indent weight halved");
141        ScoreReport.ParseInfo parse = new ScoreReport.ParseInfo(ast, facts.isParseOk(),
142            !ast ? "pp2-lexical (ast disabled)" : facts.isParseOk() ? "gsp-parse+pp2" : "pp2-tolerant",
143            facts.getErrorRegions(), facts.isParseOk() ? facts.getParserStatementCount() : facts.getStatementCount(),
144            facts.isAstAvailable());
145        return new ScoreReport(profile.getName(), profile.getCalibration(), vendor.name(), parse,
146            gateEvaluated, gatePassed, checks, agg.score, agg.attainable, agg.unfixableImpact,
147            agg.dimensions, agg.findings, agg.improvements, ctx.metrics, ctx.notes);
148    }
149
150    /** The formatter option / renderer component a rule id maps to (see the improvement list). */
151    public static String targetFor(String rule) {
152        return OptionAdvisor.target(rule);
153    }
154
155    /**
156     * Number of unquoted identifier tokens whose text differs only by case
157     * between the two token sequences (which the TOKEN_PRESERVED check has
158     * already found equal modulo case).
159     */
160    static int recasedIdentifiers(Facts a, Facts b) {
161        int n = 0;
162        int i = 0, j = 0;
163        while (i < a.tokenCount() && j < b.tokenCount()) {
164            TokenFacts x = a.token(i);
165            TokenFacts y = b.token(j);
166            if (x.isComment()) { i++; continue; }
167            if (y.isComment()) { j++; continue; }
168            boolean identifierLike = x.getType() == gudusoft.gsqlparser.ETokenType.ttidentifier
169                || x.getType() == gudusoft.gsqlparser.ETokenType.ttnonreservedkeyword
170                || (x.getType() == gudusoft.gsqlparser.ETokenType.ttkeyword && !RuleSupport.isStructuralKeyword(a, x));
171            if (identifierLike && !x.getText().equals(y.getText()) && x.getUpper().equals(y.getUpper())) {
172                n++;
173            }
174            i++;
175            j++;
176        }
177        return n;
178    }
179
180    /**
181     * Compare the two parses structurally: statement count and statement types.
182     * Returns null when either side does not parse (check not applicable),
183     * "" when equivalent, otherwise a description of the difference.
184     */
185    static String astEquivalent(String original, String formatted, EDbVendor vendor) {
186        TGSqlParser a = new TGSqlParser(vendor);
187        a.sqltext = original;
188        TGSqlParser b = new TGSqlParser(vendor);
189        b.sqltext = formatted;
190        int ra, rb;
191        try {
192            ra = a.parse();
193            rb = b.parse();
194        } catch (Throwable t) {
195            return null;
196        }
197        if (ra != 0 && rb != 0) return null;
198        if (ra != 0 || rb != 0) {
199            return String.format(Locale.ROOT, "original %s, formatted %s", ra == 0 ? "parses" : "fails to parse", rb == 0 ? "parses" : "fails to parse");
200        }
201        int na = a.sqlstatements.size(), nb = b.sqlstatements.size();
202        if (na != nb) return "statement count " + na + " vs " + nb;
203        for (int i = 0; i < na; i++) {
204            TCustomSqlStatement sa = a.sqlstatements.get(i);
205            TCustomSqlStatement sb = b.sqlstatements.get(i);
206            if (sa.sqlstatementtype != sb.sqlstatementtype) {
207                return "statement " + (i + 1) + " type " + sa.sqlstatementtype + " vs " + sb.sqlstatementtype;
208            }
209            int ta = sa.tables == null ? 0 : sa.tables.size();
210            int tb = sb.tables == null ? 0 : sb.tables.size();
211            if (ta != tb) return "statement " + (i + 1) + " table count " + ta + " vs " + tb;
212        }
213        return "";
214    }
215}