UNPKG

roam-research-mcp

Version:
320 lines (319 loc) • 10.3 kB
/** * Parser for Roam Query Block syntax * * Parses queries like: * {{[[query]]: {and: [[tag1]] [[tag2]]}}} * {{[[query]]: {or: [[a]] {not: [[b]]}}}} * {{[[query]]: {and: {between: [[January 1st, 2026]] [[January 31st, 2026]]} [[Project]]}}} */ export class QueryParseError extends Error { constructor(message, position) { super(message); this.position = position; this.name = 'QueryParseError'; } } export class QueryParser { constructor(input) { this.input = input; this.pos = 0; } /** * Parse a Roam query block string into an AST * Accepts either the full block syntax or just the query expression */ static parse(input) { const result = QueryParser.parseWithName(input); return result.query; } /** * Parse a Roam query block string into an AST with optional name * Returns both the name (if present) and the query AST */ static parseWithName(input) { const parser = new QueryParser(input); return parser.parseQueryWithName(); } /** * Extract query expression from a full query block * {{[[query]]: expression}} -> expression * {{[[query]]: "name" expression}} -> expression (name extracted separately) */ extractQueryExpression() { const trimmed = this.input.trim(); // Full query block format const fullMatch = trimmed.match(/^\{\{\[\[query\]\]:\s*(.+)\}\}$/s); if (fullMatch) { return fullMatch[1].trim(); } // Already just the expression return trimmed; } parseQueryWithName() { this.input = this.extractQueryExpression(); this.pos = 0; this.skipWhitespace(); // Check for optional name prefix (quoted string) let name; if (this.peek() === '"') { name = this.parseQuotedString(); this.skipWhitespace(); } const query = this.parseExpression(); this.skipWhitespace(); if (this.pos < this.input.length) { throw new QueryParseError(`Unexpected content after query: "${this.input.slice(this.pos)}"`, this.pos); } return { name, query }; } parseQuery() { return this.parseQueryWithName().query; } parseQuotedString() { this.expect('"'); let value = ''; while (this.pos < this.input.length && this.peek() !== '"') { if (this.peek() === '\\' && this.input[this.pos + 1] === '"') { value += '"'; this.pos += 2; } else { value += this.input[this.pos]; this.pos++; } } if (this.peek() === '"') { this.pos++; // Skip closing quote } else { throw new QueryParseError('Unclosed quoted string', this.pos); } return value; } parseExpression() { this.skipWhitespace(); if (this.peek() === '{') { return this.parseOperator(); } else if (this.input.slice(this.pos, this.pos + 2) === '[[') { return this.parseTag(); } else if (this.input.slice(this.pos, this.pos + 2) === '((') { return this.parseBlockRef(); } else { throw new QueryParseError(`Expected '{', '[[', or '((' at position ${this.pos}, found: "${this.input.slice(this.pos, this.pos + 10)}..."`, this.pos); } } parseOperator() { this.expect('{'); this.skipWhitespace(); const operator = this.parseOperatorName(); this.skipWhitespace(); this.expect(':'); this.skipWhitespace(); let node; switch (operator.toLowerCase()) { case 'and': node = this.parseAndOr('and'); break; case 'or': node = this.parseAndOr('or'); break; case 'not': node = this.parseNot(); break; case 'between': node = this.parseBetween(); break; case 'search': node = this.parseSearch(); break; case 'daily notes': node = this.parseDailyNotes(); break; case 'by': node = this.parseUserClause('by'); break; case 'created by': node = this.parseUserClause('created-by'); break; case 'edited by': node = this.parseUserClause('edited-by'); break; default: throw new QueryParseError(`Unknown operator: ${operator}`, this.pos); } this.skipWhitespace(); this.expect('}'); return node; } parseAndOr(type) { const children = []; this.skipWhitespace(); while (this.pos < this.input.length && this.peek() !== '}') { children.push(this.parseExpression()); this.skipWhitespace(); } if (children.length === 0) { throw new QueryParseError(`${type} operator requires at least one child`, this.pos); } return { type, children }; } parseNot() { this.skipWhitespace(); const child = this.parseExpression(); return { type: 'not', child }; } parseBetween() { this.skipWhitespace(); // Parse first date const startTag = this.parseTag(); this.skipWhitespace(); // Parse second date const endTag = this.parseTag(); return { type: 'between', startDate: startTag.value, endDate: endTag.value }; } parseTag() { this.expect('['); this.expect('['); let value = ''; let depth = 1; while (this.pos < this.input.length && depth > 0) { if (this.input.slice(this.pos, this.pos + 2) === ']]') { depth--; if (depth === 0) { this.pos += 2; break; } value += ']]'; this.pos += 2; } else if (this.input.slice(this.pos, this.pos + 2) === '[[') { depth++; value += '[['; this.pos += 2; } else { value += this.input[this.pos]; this.pos++; } } if (depth !== 0) { throw new QueryParseError('Unclosed tag reference', this.pos); } return { type: 'tag', value: value.trim() }; } parseBlockRef() { this.expect('('); this.expect('('); let uid = ''; while (this.pos < this.input.length) { if (this.input.slice(this.pos, this.pos + 2) === '))') { this.pos += 2; break; } uid += this.input[this.pos]; this.pos++; } if (!uid) { throw new QueryParseError('Empty block reference', this.pos); } return { type: 'block-ref', uid: uid.trim() }; } parseSearch() { // Search text is typically in quotes or just plain text until } this.skipWhitespace(); let text = ''; // Check if quoted if (this.peek() === '"') { this.pos++; // Skip opening quote while (this.pos < this.input.length && this.peek() !== '"') { if (this.peek() === '\\' && this.input[this.pos + 1] === '"') { text += '"'; this.pos += 2; } else { text += this.input[this.pos]; this.pos++; } } if (this.peek() === '"') { this.pos++; // Skip closing quote } } else { // Unquoted - read until } while (this.pos < this.input.length && this.peek() !== '}') { text += this.input[this.pos]; this.pos++; } text = text.trim(); } return { type: 'search', text }; } parseDailyNotes() { // Daily notes clause has no arguments, just empty or whitespace until } return { type: 'daily-notes' }; } parseUserClause(type) { this.skipWhitespace(); const user = this.parseUserIdentifier(); return { type, user }; } parseUserIdentifier() { // User can be in [[display name]] format or plain text if (this.input.slice(this.pos, this.pos + 2) === '[[') { const tag = this.parseTag(); return tag.value; } // Plain text until } let user = ''; while (this.pos < this.input.length && this.peek() !== '}') { user += this.input[this.pos]; this.pos++; } return user.trim(); } /** * Parse operator name, handling multi-word operators like "created by" */ parseOperatorName() { const knownMultiWord = ['created by', 'edited by', 'daily notes']; const remaining = this.input.slice(this.pos).toLowerCase(); for (const op of knownMultiWord) { if (remaining.startsWith(op)) { this.pos += op.length; return op; } } // Single word operator return this.parseIdentifier(); } parseIdentifier() { let id = ''; while (this.pos < this.input.length && /[a-zA-Z]/.test(this.input[this.pos])) { id += this.input[this.pos]; this.pos++; } return id; } skipWhitespace() { while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) { this.pos++; } } peek() { return this.input[this.pos]; } expect(char) { if (this.input[this.pos] !== char) { throw new QueryParseError(`Expected '${char}' at position ${this.pos}, found '${this.input[this.pos] || 'EOF'}'`, this.pos); } this.pos++; } }