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}