roam-research-mcp 2.4.3 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -669
- package/build/Roam_Markdown_Cheatsheet.md +24 -4
- package/build/cache/page-uid-cache.js +40 -2
- package/build/cli/batch/translator.js +1 -1
- package/build/cli/commands/batch.js +2 -0
- package/build/cli/commands/get.js +401 -14
- package/build/cli/commands/refs.js +2 -0
- package/build/cli/commands/save.js +56 -1
- package/build/cli/commands/search.js +45 -0
- package/build/cli/commands/status.js +3 -4
- package/build/cli/utils/graph.js +6 -2
- package/build/cli/utils/output.js +28 -5
- package/build/cli/utils/sort-group.js +110 -0
- package/build/config/graph-registry.js +31 -13
- package/build/config/graph-registry.test.js +42 -5
- package/build/markdown-utils.js +114 -4
- package/build/markdown-utils.test.js +125 -0
- package/build/query/generator.js +330 -0
- package/build/query/index.js +149 -0
- package/build/query/parser.js +319 -0
- package/build/query/parser.test.js +389 -0
- package/build/query/types.js +4 -0
- package/build/search/ancestor-rule.js +14 -0
- package/build/search/block-ref-search.js +1 -5
- package/build/search/hierarchy-search.js +5 -12
- package/build/search/index.js +1 -0
- package/build/search/status-search.js +10 -9
- package/build/search/tag-search.js +8 -24
- package/build/search/text-search.js +70 -27
- package/build/search/types.js +13 -0
- package/build/search/utils.js +71 -2
- package/build/server/roam-server.js +2 -1
- package/build/shared/index.js +2 -0
- package/build/shared/page-validator.js +233 -0
- package/build/shared/page-validator.test.js +128 -0
- package/build/shared/staged-batch.js +144 -0
- package/build/tools/helpers/batch-utils.js +57 -0
- package/build/tools/helpers/page-resolution.js +136 -0
- package/build/tools/helpers/refs.js +68 -0
- package/build/tools/operations/batch.js +75 -3
- package/build/tools/operations/block-retrieval.js +15 -4
- package/build/tools/operations/block-retrieval.test.js +87 -0
- package/build/tools/operations/blocks.js +1 -288
- package/build/tools/operations/memory.js +29 -91
- package/build/tools/operations/outline.js +38 -156
- package/build/tools/operations/pages.js +169 -122
- package/build/tools/operations/todos.js +5 -37
- package/build/tools/schemas.js +14 -8
- package/build/tools/tool-handlers.js +2 -2
- package/build/utils/helpers.js +27 -0
- package/package.json +1 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roam Query Block Parser and Executor
|
|
3
|
+
*
|
|
4
|
+
* Parses Roam query block syntax and executes as Datalog queries
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { QueryExecutor } from './query/index.js';
|
|
8
|
+
*
|
|
9
|
+
* const executor = new QueryExecutor(graph);
|
|
10
|
+
* const results = await executor.execute('{{[[query]]: {and: [[Project]] [[TODO]]}}}');
|
|
11
|
+
*/
|
|
12
|
+
export { QueryParser, QueryParseError } from './parser.js';
|
|
13
|
+
export { DatalogGenerator, buildDatalogQuery } from './generator.js';
|
|
14
|
+
import { q } from '@roam-research/roam-api-sdk';
|
|
15
|
+
import { QueryParser } from './parser.js';
|
|
16
|
+
import { DatalogGenerator, buildDatalogQuery } from './generator.js';
|
|
17
|
+
import { resolveRefs } from '../tools/helpers/refs.js';
|
|
18
|
+
export class QueryExecutor {
|
|
19
|
+
constructor(graph) {
|
|
20
|
+
this.graph = graph;
|
|
21
|
+
this.generator = new DatalogGenerator();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parse and execute a Roam query block
|
|
25
|
+
*
|
|
26
|
+
* @param queryBlock - The query block text, e.g., "{{[[query]]: {and: [[tag1]] [[tag2]]}}}"
|
|
27
|
+
* @param options - Execution options (limit, offset, pageUid)
|
|
28
|
+
* @returns Query results
|
|
29
|
+
*/
|
|
30
|
+
async execute(queryBlock, options = {}) {
|
|
31
|
+
try {
|
|
32
|
+
// Parse the query
|
|
33
|
+
const ast = QueryParser.parse(queryBlock);
|
|
34
|
+
// Generate Datalog clauses
|
|
35
|
+
const clauses = this.generator.generate(ast);
|
|
36
|
+
// Build the full query
|
|
37
|
+
const { query, args } = buildDatalogQuery(clauses, {
|
|
38
|
+
limit: options.limit,
|
|
39
|
+
offset: options.offset,
|
|
40
|
+
pageUid: options.pageUid,
|
|
41
|
+
orderBy: options.orderBy || '?block-uid asc'
|
|
42
|
+
});
|
|
43
|
+
// Execute query
|
|
44
|
+
const rawResults = await q(this.graph, query, args);
|
|
45
|
+
// Resolve block references in content
|
|
46
|
+
const resolvedResults = await Promise.all(rawResults.map(async ([uid, content, pageTitle]) => {
|
|
47
|
+
const resolvedContent = await resolveRefs(this.graph, content);
|
|
48
|
+
return {
|
|
49
|
+
block_uid: uid,
|
|
50
|
+
content: resolvedContent,
|
|
51
|
+
page_title: pageTitle
|
|
52
|
+
};
|
|
53
|
+
}));
|
|
54
|
+
// Get total count if pagination is used
|
|
55
|
+
let totalCount = resolvedResults.length;
|
|
56
|
+
if (options.limit !== undefined && options.limit !== -1) {
|
|
57
|
+
const countQuery = this.buildCountQuery(clauses, options.pageUid);
|
|
58
|
+
const countResults = await q(this.graph, countQuery.query, countQuery.args);
|
|
59
|
+
totalCount = countResults[0]?.[0] ?? 0;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
success: true,
|
|
63
|
+
matches: resolvedResults,
|
|
64
|
+
message: `Found ${resolvedResults.length} block(s) matching query`,
|
|
65
|
+
total_count: totalCount,
|
|
66
|
+
query // Include for debugging
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return {
|
|
71
|
+
success: false,
|
|
72
|
+
matches: [],
|
|
73
|
+
message: error instanceof Error ? error.message : String(error)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Parse a query block without executing (for validation/debugging)
|
|
79
|
+
*/
|
|
80
|
+
parse(queryBlock) {
|
|
81
|
+
const ast = QueryParser.parse(queryBlock);
|
|
82
|
+
const clauses = this.generator.generate(ast);
|
|
83
|
+
const datalog = buildDatalogQuery(clauses);
|
|
84
|
+
return { ast, datalog };
|
|
85
|
+
}
|
|
86
|
+
buildCountQuery(clauses, pageUid) {
|
|
87
|
+
let inClause = ':in $';
|
|
88
|
+
if (clauses.inputs.length > 0) {
|
|
89
|
+
inClause += ' ' + clauses.inputs.join(' ');
|
|
90
|
+
}
|
|
91
|
+
if (pageUid) {
|
|
92
|
+
inClause += ' ?target-page-uid';
|
|
93
|
+
}
|
|
94
|
+
const baseClauses = [
|
|
95
|
+
'[?b :block/string ?block-str]',
|
|
96
|
+
'[?b :block/uid ?block-uid]',
|
|
97
|
+
'[?b :block/page ?p]'
|
|
98
|
+
];
|
|
99
|
+
if (pageUid) {
|
|
100
|
+
baseClauses.push('[?p :block/uid ?target-page-uid]');
|
|
101
|
+
}
|
|
102
|
+
const allWhereClauses = [...baseClauses, ...clauses.where];
|
|
103
|
+
const query = `[:find (count ?b)
|
|
104
|
+
${inClause}
|
|
105
|
+
:where
|
|
106
|
+
${allWhereClauses.join('\n ')}]`;
|
|
107
|
+
const args = [...clauses.inputValues];
|
|
108
|
+
if (pageUid) {
|
|
109
|
+
args.push(pageUid);
|
|
110
|
+
}
|
|
111
|
+
return { query, args };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Detect if a block string contains a query block
|
|
116
|
+
*/
|
|
117
|
+
export function isQueryBlock(text) {
|
|
118
|
+
return /^\s*\{\{\[\[query\]\]:/i.test(text);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Extract all query blocks from a string (handles nested braces)
|
|
122
|
+
*/
|
|
123
|
+
export function extractQueryBlocks(text) {
|
|
124
|
+
const matches = [];
|
|
125
|
+
const prefix = '{{[[query]]:';
|
|
126
|
+
let startIdx = 0;
|
|
127
|
+
while (startIdx < text.length) {
|
|
128
|
+
const foundIdx = text.indexOf(prefix, startIdx);
|
|
129
|
+
if (foundIdx === -1)
|
|
130
|
+
break;
|
|
131
|
+
// Find matching closing }}
|
|
132
|
+
let depth = 2; // We've seen {{
|
|
133
|
+
let pos = foundIdx + prefix.length;
|
|
134
|
+
while (pos < text.length && depth > 0) {
|
|
135
|
+
if (text[pos] === '{') {
|
|
136
|
+
depth++;
|
|
137
|
+
}
|
|
138
|
+
else if (text[pos] === '}') {
|
|
139
|
+
depth--;
|
|
140
|
+
}
|
|
141
|
+
pos++;
|
|
142
|
+
}
|
|
143
|
+
if (depth === 0) {
|
|
144
|
+
matches.push(text.slice(foundIdx, pos));
|
|
145
|
+
}
|
|
146
|
+
startIdx = pos;
|
|
147
|
+
}
|
|
148
|
+
return matches;
|
|
149
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for Roam Query Block syntax
|
|
3
|
+
*
|
|
4
|
+
* Parses queries like:
|
|
5
|
+
* {{[[query]]: {and: [[tag1]] [[tag2]]}}}
|
|
6
|
+
* {{[[query]]: {or: [[a]] {not: [[b]]}}}}
|
|
7
|
+
* {{[[query]]: {and: {between: [[January 1st, 2026]] [[January 31st, 2026]]} [[Project]]}}}
|
|
8
|
+
*/
|
|
9
|
+
export class QueryParseError extends Error {
|
|
10
|
+
constructor(message, position) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.position = position;
|
|
13
|
+
this.name = 'QueryParseError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class QueryParser {
|
|
17
|
+
constructor(input) {
|
|
18
|
+
this.input = input;
|
|
19
|
+
this.pos = 0;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Parse a Roam query block string into an AST
|
|
23
|
+
* Accepts either the full block syntax or just the query expression
|
|
24
|
+
*/
|
|
25
|
+
static parse(input) {
|
|
26
|
+
const result = QueryParser.parseWithName(input);
|
|
27
|
+
return result.query;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Parse a Roam query block string into an AST with optional name
|
|
31
|
+
* Returns both the name (if present) and the query AST
|
|
32
|
+
*/
|
|
33
|
+
static parseWithName(input) {
|
|
34
|
+
const parser = new QueryParser(input);
|
|
35
|
+
return parser.parseQueryWithName();
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Extract query expression from a full query block
|
|
39
|
+
* {{[[query]]: expression}} -> expression
|
|
40
|
+
* {{[[query]]: "name" expression}} -> expression (name extracted separately)
|
|
41
|
+
*/
|
|
42
|
+
extractQueryExpression() {
|
|
43
|
+
const trimmed = this.input.trim();
|
|
44
|
+
// Full query block format
|
|
45
|
+
const fullMatch = trimmed.match(/^\{\{\[\[query\]\]:\s*(.+)\}\}$/s);
|
|
46
|
+
if (fullMatch) {
|
|
47
|
+
return fullMatch[1].trim();
|
|
48
|
+
}
|
|
49
|
+
// Already just the expression
|
|
50
|
+
return trimmed;
|
|
51
|
+
}
|
|
52
|
+
parseQueryWithName() {
|
|
53
|
+
this.input = this.extractQueryExpression();
|
|
54
|
+
this.pos = 0;
|
|
55
|
+
this.skipWhitespace();
|
|
56
|
+
// Check for optional name prefix (quoted string)
|
|
57
|
+
let name;
|
|
58
|
+
if (this.peek() === '"') {
|
|
59
|
+
name = this.parseQuotedString();
|
|
60
|
+
this.skipWhitespace();
|
|
61
|
+
}
|
|
62
|
+
const query = this.parseExpression();
|
|
63
|
+
this.skipWhitespace();
|
|
64
|
+
if (this.pos < this.input.length) {
|
|
65
|
+
throw new QueryParseError(`Unexpected content after query: "${this.input.slice(this.pos)}"`, this.pos);
|
|
66
|
+
}
|
|
67
|
+
return { name, query };
|
|
68
|
+
}
|
|
69
|
+
parseQuery() {
|
|
70
|
+
return this.parseQueryWithName().query;
|
|
71
|
+
}
|
|
72
|
+
parseQuotedString() {
|
|
73
|
+
this.expect('"');
|
|
74
|
+
let value = '';
|
|
75
|
+
while (this.pos < this.input.length && this.peek() !== '"') {
|
|
76
|
+
if (this.peek() === '\\' && this.input[this.pos + 1] === '"') {
|
|
77
|
+
value += '"';
|
|
78
|
+
this.pos += 2;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
value += this.input[this.pos];
|
|
82
|
+
this.pos++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (this.peek() === '"') {
|
|
86
|
+
this.pos++; // Skip closing quote
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
throw new QueryParseError('Unclosed quoted string', this.pos);
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
parseExpression() {
|
|
94
|
+
this.skipWhitespace();
|
|
95
|
+
if (this.peek() === '{') {
|
|
96
|
+
return this.parseOperator();
|
|
97
|
+
}
|
|
98
|
+
else if (this.input.slice(this.pos, this.pos + 2) === '[[') {
|
|
99
|
+
return this.parseTag();
|
|
100
|
+
}
|
|
101
|
+
else if (this.input.slice(this.pos, this.pos + 2) === '((') {
|
|
102
|
+
return this.parseBlockRef();
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
throw new QueryParseError(`Expected '{', '[[', or '((' at position ${this.pos}, found: "${this.input.slice(this.pos, this.pos + 10)}..."`, this.pos);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
parseOperator() {
|
|
109
|
+
this.expect('{');
|
|
110
|
+
this.skipWhitespace();
|
|
111
|
+
const operator = this.parseOperatorName();
|
|
112
|
+
this.skipWhitespace();
|
|
113
|
+
this.expect(':');
|
|
114
|
+
this.skipWhitespace();
|
|
115
|
+
let node;
|
|
116
|
+
switch (operator.toLowerCase()) {
|
|
117
|
+
case 'and':
|
|
118
|
+
node = this.parseAndOr('and');
|
|
119
|
+
break;
|
|
120
|
+
case 'or':
|
|
121
|
+
node = this.parseAndOr('or');
|
|
122
|
+
break;
|
|
123
|
+
case 'not':
|
|
124
|
+
node = this.parseNot();
|
|
125
|
+
break;
|
|
126
|
+
case 'between':
|
|
127
|
+
node = this.parseBetween();
|
|
128
|
+
break;
|
|
129
|
+
case 'search':
|
|
130
|
+
node = this.parseSearch();
|
|
131
|
+
break;
|
|
132
|
+
case 'daily notes':
|
|
133
|
+
node = this.parseDailyNotes();
|
|
134
|
+
break;
|
|
135
|
+
case 'by':
|
|
136
|
+
node = this.parseUserClause('by');
|
|
137
|
+
break;
|
|
138
|
+
case 'created by':
|
|
139
|
+
node = this.parseUserClause('created-by');
|
|
140
|
+
break;
|
|
141
|
+
case 'edited by':
|
|
142
|
+
node = this.parseUserClause('edited-by');
|
|
143
|
+
break;
|
|
144
|
+
default:
|
|
145
|
+
throw new QueryParseError(`Unknown operator: ${operator}`, this.pos);
|
|
146
|
+
}
|
|
147
|
+
this.skipWhitespace();
|
|
148
|
+
this.expect('}');
|
|
149
|
+
return node;
|
|
150
|
+
}
|
|
151
|
+
parseAndOr(type) {
|
|
152
|
+
const children = [];
|
|
153
|
+
this.skipWhitespace();
|
|
154
|
+
while (this.pos < this.input.length && this.peek() !== '}') {
|
|
155
|
+
children.push(this.parseExpression());
|
|
156
|
+
this.skipWhitespace();
|
|
157
|
+
}
|
|
158
|
+
if (children.length === 0) {
|
|
159
|
+
throw new QueryParseError(`${type} operator requires at least one child`, this.pos);
|
|
160
|
+
}
|
|
161
|
+
return { type, children };
|
|
162
|
+
}
|
|
163
|
+
parseNot() {
|
|
164
|
+
this.skipWhitespace();
|
|
165
|
+
const child = this.parseExpression();
|
|
166
|
+
return { type: 'not', child };
|
|
167
|
+
}
|
|
168
|
+
parseBetween() {
|
|
169
|
+
this.skipWhitespace();
|
|
170
|
+
// Parse first date
|
|
171
|
+
const startTag = this.parseTag();
|
|
172
|
+
this.skipWhitespace();
|
|
173
|
+
// Parse second date
|
|
174
|
+
const endTag = this.parseTag();
|
|
175
|
+
return {
|
|
176
|
+
type: 'between',
|
|
177
|
+
startDate: startTag.value,
|
|
178
|
+
endDate: endTag.value
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
parseTag() {
|
|
182
|
+
this.expect('[');
|
|
183
|
+
this.expect('[');
|
|
184
|
+
let value = '';
|
|
185
|
+
let depth = 1;
|
|
186
|
+
while (this.pos < this.input.length && depth > 0) {
|
|
187
|
+
if (this.input.slice(this.pos, this.pos + 2) === ']]') {
|
|
188
|
+
depth--;
|
|
189
|
+
if (depth === 0) {
|
|
190
|
+
this.pos += 2;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
value += ']]';
|
|
194
|
+
this.pos += 2;
|
|
195
|
+
}
|
|
196
|
+
else if (this.input.slice(this.pos, this.pos + 2) === '[[') {
|
|
197
|
+
depth++;
|
|
198
|
+
value += '[[';
|
|
199
|
+
this.pos += 2;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
value += this.input[this.pos];
|
|
203
|
+
this.pos++;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (depth !== 0) {
|
|
207
|
+
throw new QueryParseError('Unclosed tag reference', this.pos);
|
|
208
|
+
}
|
|
209
|
+
return { type: 'tag', value: value.trim() };
|
|
210
|
+
}
|
|
211
|
+
parseBlockRef() {
|
|
212
|
+
this.expect('(');
|
|
213
|
+
this.expect('(');
|
|
214
|
+
let uid = '';
|
|
215
|
+
while (this.pos < this.input.length) {
|
|
216
|
+
if (this.input.slice(this.pos, this.pos + 2) === '))') {
|
|
217
|
+
this.pos += 2;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
uid += this.input[this.pos];
|
|
221
|
+
this.pos++;
|
|
222
|
+
}
|
|
223
|
+
if (!uid) {
|
|
224
|
+
throw new QueryParseError('Empty block reference', this.pos);
|
|
225
|
+
}
|
|
226
|
+
return { type: 'block-ref', uid: uid.trim() };
|
|
227
|
+
}
|
|
228
|
+
parseSearch() {
|
|
229
|
+
// Search text is typically in quotes or just plain text until }
|
|
230
|
+
this.skipWhitespace();
|
|
231
|
+
let text = '';
|
|
232
|
+
// Check if quoted
|
|
233
|
+
if (this.peek() === '"') {
|
|
234
|
+
this.pos++; // Skip opening quote
|
|
235
|
+
while (this.pos < this.input.length && this.peek() !== '"') {
|
|
236
|
+
if (this.peek() === '\\' && this.input[this.pos + 1] === '"') {
|
|
237
|
+
text += '"';
|
|
238
|
+
this.pos += 2;
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
text += this.input[this.pos];
|
|
242
|
+
this.pos++;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (this.peek() === '"') {
|
|
246
|
+
this.pos++; // Skip closing quote
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
// Unquoted - read until }
|
|
251
|
+
while (this.pos < this.input.length && this.peek() !== '}') {
|
|
252
|
+
text += this.input[this.pos];
|
|
253
|
+
this.pos++;
|
|
254
|
+
}
|
|
255
|
+
text = text.trim();
|
|
256
|
+
}
|
|
257
|
+
return { type: 'search', text };
|
|
258
|
+
}
|
|
259
|
+
parseDailyNotes() {
|
|
260
|
+
// Daily notes clause has no arguments, just empty or whitespace until }
|
|
261
|
+
return { type: 'daily-notes' };
|
|
262
|
+
}
|
|
263
|
+
parseUserClause(type) {
|
|
264
|
+
this.skipWhitespace();
|
|
265
|
+
const user = this.parseUserIdentifier();
|
|
266
|
+
return { type, user };
|
|
267
|
+
}
|
|
268
|
+
parseUserIdentifier() {
|
|
269
|
+
// User can be in [[display name]] format or plain text
|
|
270
|
+
if (this.input.slice(this.pos, this.pos + 2) === '[[') {
|
|
271
|
+
const tag = this.parseTag();
|
|
272
|
+
return tag.value;
|
|
273
|
+
}
|
|
274
|
+
// Plain text until }
|
|
275
|
+
let user = '';
|
|
276
|
+
while (this.pos < this.input.length && this.peek() !== '}') {
|
|
277
|
+
user += this.input[this.pos];
|
|
278
|
+
this.pos++;
|
|
279
|
+
}
|
|
280
|
+
return user.trim();
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Parse operator name, handling multi-word operators like "created by"
|
|
284
|
+
*/
|
|
285
|
+
parseOperatorName() {
|
|
286
|
+
const knownMultiWord = ['created by', 'edited by', 'daily notes'];
|
|
287
|
+
const remaining = this.input.slice(this.pos).toLowerCase();
|
|
288
|
+
for (const op of knownMultiWord) {
|
|
289
|
+
if (remaining.startsWith(op)) {
|
|
290
|
+
this.pos += op.length;
|
|
291
|
+
return op;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Single word operator
|
|
295
|
+
return this.parseIdentifier();
|
|
296
|
+
}
|
|
297
|
+
parseIdentifier() {
|
|
298
|
+
let id = '';
|
|
299
|
+
while (this.pos < this.input.length && /[a-zA-Z]/.test(this.input[this.pos])) {
|
|
300
|
+
id += this.input[this.pos];
|
|
301
|
+
this.pos++;
|
|
302
|
+
}
|
|
303
|
+
return id;
|
|
304
|
+
}
|
|
305
|
+
skipWhitespace() {
|
|
306
|
+
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
|
|
307
|
+
this.pos++;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
peek() {
|
|
311
|
+
return this.input[this.pos];
|
|
312
|
+
}
|
|
313
|
+
expect(char) {
|
|
314
|
+
if (this.input[this.pos] !== char) {
|
|
315
|
+
throw new QueryParseError(`Expected '${char}' at position ${this.pos}, found '${this.input[this.pos] || 'EOF'}'`, this.pos);
|
|
316
|
+
}
|
|
317
|
+
this.pos++;
|
|
318
|
+
}
|
|
319
|
+
}
|