001package gudusoft.gsqlparser.pp.score;
002
003import gudusoft.gsqlparser.pp.score.json.JsonIn;
004
005import java.io.ByteArrayOutputStream;
006import java.io.File;
007import java.io.FileInputStream;
008import java.io.IOException;
009import java.io.InputStream;
010import java.nio.charset.Charset;
011import java.util.Collections;
012import java.util.LinkedHashMap;
013import java.util.Locale;
014import java.util.Map;
015
016/**
017 * A style profile: the target layout the scorer measures against.
018 *
019 * <p>"Better" depends on the intended style (river-aligned Celko layout vs the
020 * compact sqlfmt look vs the ISF default), so every threshold, weight, target
021 * width and alignment expectation lives in a JSON resource under
022 * {@code /gudusoft/gsqlparser/pp/score/profiles/} and nothing numeric is
023 * hard-coded in the rules. Three profiles ship: {@code isf-default},
024 * {@code river}, {@code compact}. A user profile file may set
025 * {@code "extends": "isf-default"} to inherit everything it does not override.
026 *
027 * <p>Scores are only comparable within one profile and one corpus while the
028 * profile's {@link #getCalibration() calibration} is {@code heuristic-v0}.
029 */
030public final class ScoreProfile {
031
032    /** Where the built-in profiles live on the classpath. */
033    public static final String RESOURCE_DIR = "/gudusoft/gsqlparser/pp/score/profiles/";
034
035    public static final String ISF_DEFAULT = "isf-default";
036    public static final String RIVER = "river";
037    public static final String COMPACT = "compact";
038
039    public enum ClauseAlign { LEFT, RIVER }
040    public enum JoinOnIndent { SAME, NESTED, RIVER }
041    /** Where JOIN clause keywords sit: on the clause spine, or at the block's content column (ISF style). */
042    public enum JoinIndent { SPINE, CONTENT }
043
044    private final String name;
045    private final String calibration;
046    private final String description;
047    private final int targetWidth;
048    private final int indentUnit;          // 0 = auto-detect
049    private final int indentUnitFallback;
050    private final int tabSize;
051    private final ClauseAlign clauseAlign;
052    private final boolean andOrLineStart;
053    private final JoinOnIndent joinOnIndent;
054    private final JoinIndent joinIndent;
055    private final boolean joinOnNewLine;
056    private final boolean aliasAlign;
057    private final boolean blankBetweenStatements;
058    private final boolean errorRegionPreserve;
059    private final boolean strictIdentifierCase;
060    private final int inlineListMaxItems;
061    private final int inlineListMaxLen;
062    private final int inlineBlockMaxLen;
063    private final Map<String, Double> weights;
064    private final Map<String, Double> thresholds;
065
066    private ScoreProfile(Map<String, Object> m) {
067        this.name = JsonIn.str(m, "name", "custom");
068        this.calibration = JsonIn.str(m, "calibration", "heuristic-v0");
069        this.description = JsonIn.str(m, "description", "");
070        this.targetWidth = JsonIn.integer(m, "targetWidth", 100);
071        this.indentUnit = JsonIn.integer(m, "indentUnit", 0);
072        this.indentUnitFallback = JsonIn.integer(m, "indentUnitFallback", 2);
073        this.tabSize = JsonIn.integer(m, "tabSize", 4);
074        this.clauseAlign = ClauseAlign.valueOf(JsonIn.str(m, "clauseAlign", "left").toUpperCase(Locale.ROOT));
075        this.andOrLineStart = JsonIn.bool(m, "andOrLineStart", true);
076        this.joinOnIndent = JoinOnIndent.valueOf(JsonIn.str(m, "joinOnIndent", "same").toUpperCase(Locale.ROOT));
077        this.joinIndent = JoinIndent.valueOf(JsonIn.str(m, "joinIndent", "content").toUpperCase(Locale.ROOT));
078        this.joinOnNewLine = JsonIn.bool(m, "joinOnNewLine", true);
079        this.aliasAlign = JsonIn.bool(m, "aliasAlign", false);
080        this.blankBetweenStatements = JsonIn.bool(m, "blankBetweenStatements", false);
081        this.errorRegionPreserve = JsonIn.bool(m, "errorRegionPreserve", true);
082        this.strictIdentifierCase = JsonIn.bool(m, "strictIdentifierCase", false);
083        this.inlineListMaxItems = JsonIn.integer(m, "inlineListMaxItems", 3);
084        this.inlineListMaxLen = JsonIn.integer(m, "inlineListMaxLen", 60);
085        this.inlineBlockMaxLen = JsonIn.integer(m, "inlineBlockMaxLen", 60);
086        Map<String, Double> w = new LinkedHashMap<String, Double>();
087        for (Map.Entry<String, Object> e : JsonIn.obj(m, "weights").entrySet()) {
088            if (e.getValue() instanceof Number) w.put(e.getKey(), ((Number) e.getValue()).doubleValue());
089        }
090        this.weights = Collections.unmodifiableMap(w);
091        Map<String, Double> t = new LinkedHashMap<String, Double>();
092        for (Map.Entry<String, Object> e : JsonIn.obj(m, "thresholds").entrySet()) {
093            if (e.getValue() instanceof Number) t.put(e.getKey(), ((Number) e.getValue()).doubleValue());
094        }
095        this.thresholds = Collections.unmodifiableMap(t);
096    }
097
098    // ---- loading --------------------------------------------------------
099
100    /** Load a built-in profile by name ({@code isf-default}, {@code river}, {@code compact}). */
101    public static ScoreProfile builtin(String name) {
102        if (name == null) throw new NullPointerException("name");
103        String text = readResource(RESOURCE_DIR + name + ".json");
104        if (text == null) {
105            throw new IllegalArgumentException("Unknown built-in profile: " + name);
106        }
107        return fromJson(text);
108    }
109
110    /** The default profile ({@code isf-default}). */
111    public static ScoreProfile defaults() {
112        return builtin(ISF_DEFAULT);
113    }
114
115    /**
116     * Resolve a profile from a built-in name or a file path. A path is
117     * recognised by existing on disk or ending in {@code .json}.
118     */
119    public static ScoreProfile load(String nameOrPath) throws IOException {
120        if (nameOrPath == null || nameOrPath.isEmpty()) return defaults();
121        File f = new File(nameOrPath);
122        if (f.isFile()) {
123            return fromJson(readFile(f));
124        }
125        if (nameOrPath.endsWith(".json")) {
126            throw new IOException("Profile file not found: " + nameOrPath);
127        }
128        return builtin(nameOrPath);
129    }
130
131    /** Parse a profile from JSON text, honouring {@code "extends"}. */
132    public static ScoreProfile fromJson(String json) {
133        Map<String, Object> m = JsonIn.parseObject(json);
134        Object base = m.get("extends");
135        if (base != null) {
136            String baseText = readResource(RESOURCE_DIR + base + ".json");
137            if (baseText == null) {
138                throw new IllegalArgumentException("Profile extends unknown base: " + base);
139            }
140            Map<String, Object> merged = JsonIn.parseObject(baseText);
141            deepMerge(merged, m);
142            merged.remove("extends");
143            m = merged;
144        }
145        return new ScoreProfile(m);
146    }
147
148    @SuppressWarnings("unchecked")
149    private static void deepMerge(Map<String, Object> into, Map<String, Object> from) {
150        for (Map.Entry<String, Object> e : from.entrySet()) {
151            Object existing = into.get(e.getKey());
152            if (existing instanceof Map && e.getValue() instanceof Map) {
153                deepMerge((Map<String, Object>) existing, (Map<String, Object>) e.getValue());
154            } else {
155                into.put(e.getKey(), e.getValue());
156            }
157        }
158    }
159
160    private static String readResource(String path) {
161        InputStream in = ScoreProfile.class.getResourceAsStream(path);
162        if (in == null) return null;
163        try {
164            return readAll(in);
165        } catch (IOException e) {
166            return null;
167        }
168    }
169
170    private static String readFile(File f) throws IOException {
171        InputStream in = new FileInputStream(f);
172        try {
173            return readAll(in);
174        } finally {
175            in.close();
176        }
177    }
178
179    private static String readAll(InputStream in) throws IOException {
180        try {
181            ByteArrayOutputStream bos = new ByteArrayOutputStream();
182            byte[] buf = new byte[8192];
183            int n;
184            while ((n = in.read(buf)) > 0) bos.write(buf, 0, n);
185            return new String(bos.toByteArray(), Charset.forName("UTF-8"));
186        } finally {
187            in.close();
188        }
189    }
190
191    // ---- accessors ------------------------------------------------------
192
193    public String getName() { return name; }
194    /** {@code heuristic-v0} until pairwise human votes re-fit the weights ({@code bt-v1}). */
195    public String getCalibration() { return calibration; }
196    public String getDescription() { return description; }
197    /** W: target line width. */
198    public int getTargetWidth() { return targetWidth; }
199    /** U: indent unit; {@code 0} means auto-detect from the output. */
200    public int getIndentUnit() { return indentUnit; }
201    public int getIndentUnitFallback() { return indentUnitFallback; }
202    public int getTabSize() { return tabSize; }
203    public ClauseAlign getClauseAlign() { return clauseAlign; }
204    /** True: AND/OR start their line; false: AND/OR end the previous line. */
205    public boolean isAndOrLineStart() { return andOrLineStart; }
206    public JoinOnIndent getJoinOnIndent() { return joinOnIndent; }
207    public JoinIndent getJoinIndent() { return joinIndent; }
208    public boolean isJoinOnNewLine() { return joinOnNewLine; }
209    public boolean isAliasAlign() { return aliasAlign; }
210    public boolean isBlankBetweenStatements() { return blankBetweenStatements; }
211    public boolean isErrorRegionPreserve() { return errorRegionPreserve; }
212    /**
213     * When true, an unquoted identifier whose case changed between original and
214     * formatted text fails the {@code IDENTIFIER_CASE} gate check (relevant for
215     * case-sensitive object names, e.g. MySQL with lower_case_table_names=0).
216     * Off by default because formatters recase identifiers on purpose
217     * ({@code GFmtOpt.caseIdentifier}, function names); the count is always reported.
218     */
219    public boolean isStrictIdentifierCase() { return strictIdentifierCase; }
220    public int getInlineListMaxItems() { return inlineListMaxItems; }
221    public int getInlineListMaxLen() { return inlineListMaxLen; }
222    public int getInlineBlockMaxLen() { return inlineBlockMaxLen; }
223
224    /** Dimension id to weight, as declared (before any automatic adjustment). */
225    public Map<String, Double> getWeights() { return weights; }
226
227    public double weight(String dimension) {
228        Double w = weights.get(dimension);
229        return w == null ? 0.0 : w.doubleValue();
230    }
231
232    /** All rule thresholds ({@code RULE.param} keys). */
233    public Map<String, Double> getThresholds() { return thresholds; }
234
235    /**
236     * A rule threshold. Every number a rule uses comes from here; a missing key
237     * is a profile authoring error and fails loudly rather than silently using
238     * a hidden default.
239     */
240    public double thr(String key) {
241        Double v = thresholds.get(key);
242        if (v == null) {
243            throw new IllegalStateException("Profile '" + name + "' has no threshold '" + key + "'");
244        }
245        return v.doubleValue();
246    }
247
248    /** Whether the profile defines a threshold. */
249    public boolean hasThr(String key) {
250        return thresholds.containsKey(key);
251    }
252
253    @Override
254    public String toString() {
255        return "ScoreProfile[" + name + " W=" + targetWidth + " U=" + (indentUnit == 0 ? "auto" : String.valueOf(indentUnit))
256            + " " + clauseAlign + "]";
257    }
258}