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