TaxDslParser.java
package com.taxonomy.dsl.parser;
import com.taxonomy.dsl.ast.*;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Block-structured parser for the TaxDSL v2 language.
*
* <p>DSL v2 uses explicit block delimiters ({@code {} / {@code }}) and
* semicolon-terminated {@code key: value;} properties:
* <pre>
* element CP-1023 type Capability {
* title: "Secure Communications";
* description: "Ability to provide secure communications";
* }
* </pre>
*
* <p>The parser is intentionally simple and tolerant:
* <ul>
* <li>Blocks start with a keyword line ending in {@code {}.</li>
* <li>Properties use {@code key: value;} syntax inside the block.</li>
* <li>Blocks end with a line containing {@code }}.</li>
* <li>Indentation is ignored — only {@code {} and {@code }} are structural.</li>
* <li>Unknown block types and unknown properties are preserved.</li>
* <li>Quoted string values are unquoted; unquoted values are kept as-is.</li>
* </ul>
*/
public class TaxDslParser {
private static final Set<String> KNOWN_BLOCK_TYPES = Set.of(
"meta", "element", "relation", "requirement", "mapping", "view", "evidence",
// Provenance block types (v2.1)
"source", "sourceVersion", "sourceFragment", "requirementSourceLink", "candidate");
/**
* Pattern to extract a quoted string value.
* Supports escaped quotes ({@code \"}) inside the string.
* Uses a possessive quantifier ({@code *+}) to prevent catastrophic
* backtracking (ReDoS) on malformed input.
*/
private static final Pattern QUOTED_VALUE = Pattern.compile("\"((?:[^\"\\\\]|\\\\.)*+)\"");
/**
* Parse DSL text into a {@link DocumentAst}.
*
* @param text the DSL source text
* @param fileName optional file name for source locations (may be {@code null})
* @return the parsed document AST
*/
public DocumentAst parse(String text, String fileName) {
if (text == null || text.isBlank()) {
return new DocumentAst(null, List.of());
}
List<String> lines = text.lines().toList();
MetaAst meta = null;
List<BlockAst> blocks = new ArrayList<>();
int i = 0;
while (i < lines.size()) {
String stripped = withoutComment(lines.get(i)).strip();
// Skip blank lines and comments
if (stripped.isEmpty() || stripped.startsWith("#")) {
i++;
continue;
}
// Look for a block opening: keyword ... {
if (blockDelimiter(stripped) > 0) {
String headerPart = stripped.substring(0, stripped.length() - 1).strip();
String keyword = firstToken(headerPart);
List<String> headerTokens = parseHeaderTokens(headerPart);
// Collect body lines until closing }
int bodyStart = i + 1;
int bodyEnd = bodyStart;
int depth = 1;
while (bodyEnd < lines.size() && depth > 0) {
depth += blockDelimiter(lines.get(bodyEnd));
if (depth > 0) bodyEnd++;
else break;
}
List<PropertyAst> properties = new ArrayList<>();
Map<String, String> extensions = new LinkedHashMap<>();
for (int j = bodyStart; j < bodyEnd; j++) {
String bodyLine = withoutComment(lines.get(j)).strip();
if (bodyLine.isEmpty() || bodyLine.startsWith("#")) continue;
PropertyAst prop = parseProperty(bodyLine, fileName, j + 1);
if (prop != null) {
properties.add(prop);
if (prop.isExtension()) {
extensions.put(prop.key(), prop.value());
}
}
}
SourceLocation loc = new SourceLocation(fileName, i + 1, 1);
if ("meta".equals(keyword)) {
String language = findProperty(properties, "language");
String version = findProperty(properties, "version");
String namespace = findProperty(properties, "namespace");
meta = new MetaAst(language, version, namespace, loc);
} else {
BlockAst block = new BlockAst(keyword, headerTokens, properties,
List.of(), extensions, loc);
blocks.add(block);
}
i = bodyEnd + 1; // skip past closing }
} else {
i++;
}
}
return new DocumentAst(meta, blocks);
}
/** Convenience overload without file name. */
public DocumentAst parse(String text) {
return parse(text, null);
}
/** Block structure excludes quoted values and full-line or trailing comments. Shared with source patches. */
public static int blockDelimiter(String line) {
String structural = QUOTED_VALUE.matcher(withoutComment(line)).replaceAll("\"\"").strip();
if (structural.startsWith("}")) return -1;
return structural.endsWith("{") ? 1 : 0;
}
/** Keep literal hashes inside quoted values, including escaped quotes and backslashes. */
public static String withoutComment(String line) {
boolean quoted = false;
boolean escaped = false;
for (int index = 0; index < line.length(); index++) {
char value = line.charAt(index);
if (escaped) { escaped = false; continue; }
if (quoted && value == '\\') { escaped = true; continue; }
if (value == '"') quoted = !quoted;
if (!quoted && value == '#') return line.substring(0, index);
}
return line;
}
private String firstToken(String line) {
if (line.isEmpty()) return "";
int space = line.indexOf(' ');
return space < 0 ? line : line.substring(0, space);
}
/**
* Parse header tokens from the block header line (excluding the keyword itself).
* Tokens are split by whitespace.
*/
private List<String> parseHeaderTokens(String headerLine) {
String[] parts = headerLine.split("\\s+");
if (parts.length <= 1) return List.of();
return List.of(Arrays.copyOfRange(parts, 1, parts.length));
}
/**
* Parse a single property line in v2 format: {@code key: "value";} or {@code key: 0.92;}.
*
* <p>The colon after the key and the trailing semicolon are stripped.
*/
private PropertyAst parseProperty(String line, String fileName, int lineNumber) {
// Strip trailing semicolon
if (line.endsWith(";")) {
line = line.substring(0, line.length() - 1).stripTrailing();
}
// Split on first colon
int colonIdx = line.indexOf(':');
if (colonIdx < 0) {
// Fallback: try space-separated (for tolerance)
return parseSpaceSeparatedProperty(line, fileName, lineNumber);
}
String key = line.substring(0, colonIdx).strip();
String rest = line.substring(colonIdx + 1).strip();
String value;
Matcher m = QUOTED_VALUE.matcher(rest);
if (m.find()) {
value = unescapeQuotedValue(m.group(1));
} else {
value = rest;
}
return new PropertyAst(key, value, new SourceLocation(fileName, lineNumber, 1));
}
/**
* Fallback parser for space-separated properties (tolerance for edge cases).
*/
private PropertyAst parseSpaceSeparatedProperty(String line, String fileName, int lineNumber) {
int firstSpace = line.indexOf(' ');
if (firstSpace < 0) {
return new PropertyAst(line, "", new SourceLocation(fileName, lineNumber, 1));
}
String key = line.substring(0, firstSpace);
String rest = line.substring(firstSpace).strip();
Matcher m = QUOTED_VALUE.matcher(rest);
String value;
if (m.find()) {
value = unescapeQuotedValue(m.group(1));
} else {
value = rest;
}
return new PropertyAst(key, value, new SourceLocation(fileName, lineNumber, 1));
}
private String findProperty(List<PropertyAst> properties, String key) {
return properties.stream()
.filter(p -> p.key().equals(key))
.map(PropertyAst::value)
.findFirst()
.orElse(null);
}
/**
* Unescape a quoted string value. In addition to quotes and backslashes,
* control characters are represented by the portable single-line escapes
* {@code \n}, {@code \r}, and {@code \t} so long requirement texts remain
* lossless in line-oriented Git diffs.
*/
private String unescapeQuotedValue(String raw) {
if (raw == null || raw.indexOf('\\') < 0) return raw;
StringBuilder sb = new StringBuilder(raw.length());
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
if (c == '\\' && i + 1 < raw.length()) {
char next = raw.charAt(i + 1);
switch (next) {
case '"' -> sb.append('"');
case '\\' -> sb.append('\\');
case 'n' -> sb.append('\n');
case 'r' -> sb.append('\r');
case 't' -> sb.append('\t');
default -> {
// Preserve unknown escapes literally for forward compatibility.
sb.append('\\').append(next);
}
}
i++;
continue;
}
sb.append(c);
}
return sb.toString();
}
}