go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/web/rpcexplorer/src/data/autocomplete.tsx (about) 1 // Copyright 2023 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import * as prpc from './prpc'; 16 17 18 // Defines the lexical meaning of a parsed token. 19 export enum TokenKind { 20 // Things like `{`, `:`, etc. 21 Punctuation, 22 // Complete and correct string: `"something"`. 23 String, 24 // Incomplete string: `"somethi`. 25 IncompleteString, 26 // A string that has improper escaping inside. 27 BrokenString, 28 // Numbers and literals like `null`, `true`, etc. 29 EverythingElse, 30 } 31 32 33 // A lexically significant substring of a JSON document. 34 export interface Token { 35 kind: TokenKind; // e.g. TokenKind.String 36 raw: string; // e.g. `"something"` 37 val: string; // e.g. `something` 38 } 39 40 41 // tokenizeJSON calls the callback for every JSON lexical token it visits. 42 // 43 // It doesn't care about JSON syntax, only its lexems. 44 // 45 // See https://www.rfc-editor.org/rfc/rfc7159#section-2. 46 export const tokenizeJSON = (text: string, visiter: (tok: Token) => void) => { 47 const emit = (kind: TokenKind, raw: string) => { 48 let val = ''; 49 switch (kind) { 50 case TokenKind.String: 51 try { 52 val = JSON.parse(raw); 53 } catch { 54 kind = TokenKind.BrokenString; 55 } 56 break; 57 case TokenKind.IncompleteString: 58 try { 59 val = JSON.parse(raw + '"'); 60 } catch { 61 // It is OK, incomplete strings may not be parsable. 62 } 63 break; 64 default: 65 val = raw; 66 } 67 visiter({ kind, raw, val }); 68 }; 69 70 const isWhitespace = (ch: string): boolean => { 71 return ( 72 ch == ' ' || 73 ch == '\t' || 74 ch == '\n' || 75 ch == '\r' 76 ); 77 }; 78 const isPunctuation = (ch: string): boolean => { 79 return ( 80 ch == '{' || 81 ch == '}' || 82 ch == '[' || 83 ch == ']' || 84 ch == ':' || 85 ch == ',' 86 ); 87 }; 88 89 let idx = 0; 90 while (idx < text.length) { 91 const ch = text[idx]; 92 93 // Skip whitespace between lexems. 94 if (isWhitespace(ch)) { 95 idx++; 96 continue; 97 } 98 99 // Emit punctuation lexems. 100 if (isPunctuation(ch)) { 101 emit(TokenKind.Punctuation, ch); 102 idx++; 103 continue; 104 } 105 106 // Recognize strings (perhaps escaped). We just need to correctly detect 107 // where it ends. Actual deescaping will be done with JSON.parse(...). 108 if (ch == '"') { 109 let end = idx + 1; 110 let kind = TokenKind.IncompleteString; 111 while (end < text.length) { 112 const ch = text[end]; 113 if (ch == '\\') { 114 end += 2; // skip '\\' itself and one following escaped character 115 continue; 116 } 117 if (ch == '\n' || ch == '\r') { 118 break; // an incomplete string 119 } 120 end++; // include `ch` in the final value 121 if (ch == '"') { 122 kind = TokenKind.String; // the string is complete now 123 break; 124 } 125 } 126 emit(kind, text.substring(idx, end)); 127 idx = end; 128 continue; 129 } 130 131 // Recognize other lexems we don't care about (like numbers or keywords). 132 let end = idx + 1; 133 while (end < text.length) { 134 const ch = text[end]; 135 if (isWhitespace(ch) || isPunctuation(ch)) { 136 break; 137 } 138 end++; 139 } 140 emit(TokenKind.EverythingElse, text.substring(idx, end)); 141 idx = end; 142 } 143 }; 144 145 146 // Describes at what syntactical position we need to do auto-completion. 147 export enum State { 148 // E.g. `{`. 149 BeforeKey, 150 // E.g. `{ "zz`. 151 InsideKey, 152 // E.g. `{ "zzz"`. 153 AfterKey, 154 // E.g. `{ "zzz": `. 155 BeforeValue, 156 // E.g. `{ "zzz": "xx`. 157 InsideValue, 158 // E.g. `{ "zzz": "xxx"`. 159 AfterValue, 160 } 161 162 163 // Outcome of parsing a JSON prefix that describes how to auto-complete it. 164 export interface Context { 165 // Describes at what syntactical position we need to do auto-completion. 166 state: State; 167 // A path inside the JSON object to the field being auto-completed. 168 path: PathItem[]; 169 } 170 171 172 // An element of a JSON field path. 173 export interface PathItem { 174 kind: 'root' | 'obj' | 'list'; 175 key?: Token; // perhaps incomplete 176 value?: Token; // perhaps incomplete 177 } 178 179 180 // Takes a JSON document prefix (e.g. as it is being typed) and returns details 181 // of how to do auto-completion in it. 182 // 183 // Doesn't really fully check JSON syntax, just recognizes enough of it to 184 // build the key path. If at some point it gets confused, returns undefined. 185 export const getContext = (text: string): Context | undefined => { 186 class Confused extends Error {} 187 188 let state = State.BeforeValue; 189 const path: PathItem[] = [{ kind: 'root' }]; 190 191 // State machine that consumes tokens and builds Context. 192 type Advance = (tok: Token) => State | undefined; 193 const perState: { [key in State]: Advance } = { 194 [State.BeforeKey]: (tok) => { 195 switch (tok.kind) { 196 case TokenKind.IncompleteString: 197 path[path.length - 1].key = tok; 198 return State.InsideKey; 199 case TokenKind.String: 200 case TokenKind.EverythingElse: 201 path[path.length - 1].key = tok; 202 return State.AfterKey; 203 default: 204 return undefined; 205 } 206 }, 207 208 [State.InsideKey]: () => { 209 // InsideKey is a terminal state, there should not be tokens after it. 210 return undefined; 211 }, 212 213 [State.AfterKey]: (tok) => { 214 if (tok.kind == TokenKind.Punctuation && tok.val == ':') { 215 return State.BeforeValue; 216 } 217 return undefined; 218 }, 219 220 [State.BeforeValue]: (tok) => { 221 switch (tok.kind) { 222 case TokenKind.Punctuation: 223 if (tok.val == '{') { 224 path.push({ kind: 'obj' }); 225 return State.BeforeKey; 226 } 227 if (tok.val == '[') { 228 path.push({ kind: 'list' }); 229 return State.BeforeValue; 230 } 231 return undefined; 232 case TokenKind.String: 233 case TokenKind.IncompleteString: 234 case TokenKind.EverythingElse: 235 path[path.length - 1].value = tok; 236 return ( 237 tok.kind == TokenKind.IncompleteString ? 238 State.InsideValue : 239 State.AfterValue 240 ); 241 default: 242 return undefined; 243 } 244 }, 245 246 [State.InsideValue]: () => { 247 // InsideValue is a terminal state, there should not be tokens after it. 248 return undefined; 249 }, 250 251 [State.AfterValue]: (tok) => { 252 if (tok.kind == TokenKind.Punctuation) { 253 switch (tok.val) { 254 case ',': 255 switch (path[path.length - 1].kind) { 256 case 'obj': 257 return State.BeforeKey; 258 case 'list': 259 return State.BeforeValue; 260 } 261 return undefined; 262 case ']': 263 case '}': 264 { 265 const expect = tok.val == ']' ? 'list' : 'obj'; 266 const last = path.pop(); 267 if (!last || last.kind != expect) { 268 return undefined; 269 } 270 return State.AfterValue; 271 } 272 } 273 } 274 return undefined; 275 }, 276 }; 277 278 const visiter = (tok: Token) => { 279 const next = perState[state](tok); 280 if (next == undefined) { 281 throw new Confused(); 282 } 283 state = next; 284 }; 285 286 try { 287 tokenizeJSON(text, visiter); 288 } catch (err) { 289 if (err instanceof Confused) { 290 return undefined; 291 } 292 throw err; 293 } 294 295 return { state, path }; 296 }; 297 298 299 // Completion knows how to list fields and values of a particular JSON 300 // object or a list based on a protobuf schema. 301 // 302 // It represents completion options at some point in a JSON document. Most often 303 // it maps to a prpc.Message or a repeated field, but it also supports value 304 // completion of `map<...>` fields, since they are represented as objects in 305 // protobuf JSON encoding. 306 export interface Completion { 307 // All known fields of the current object. 308 fields: prpc.Field[]; 309 // Enumeration of *values* of a particular field or a list element. 310 values(field: string): Value[]; 311 } 312 313 314 // A possible value of a string-typed JSON field. 315 export interface Value { 316 // Actual value. 317 value: string; 318 // Documentation string for this value. 319 doc: string; 320 } 321 322 323 // TraversalContext is used internally by completionForPath. 324 // 325 // It is essentially a "cursor" inside a protobuf descriptor tree, with methods 326 // to go deeper. 327 interface TraversalContext { 328 // completion produces the Completion matching this context, if any. 329 completion(): Completion | undefined; 330 // visitField is used by completionForPath to descend into a field. 331 visitField(key: string): TraversalContext | undefined; 332 // visitIndex is used by completionForPath to descend into a list element. 333 visitIndex(): TraversalContext | undefined; 334 } 335 336 337 // MessageTraversal implements TraversalContext using prpc.Message as a source. 338 // 339 // It can descend into message fields. 340 class MessageTraversal implements TraversalContext { 341 constructor(readonly msg: prpc.Message) {} 342 343 completion(): Completion { 344 return { 345 fields: this.msg.fields, 346 values: (field) => { 347 const fieldObj = this.msg.fieldByJsonName(field); 348 if (fieldObj && !fieldObj.repeated) { 349 return fieldValues(fieldObj); 350 } 351 return []; 352 }, 353 }; 354 } 355 356 visitField(key: string): TraversalContext | undefined { 357 const field = this.msg.fieldByJsonName(key); 358 if (!field) { 359 return undefined; 360 } 361 const inner = field.message; 362 if (field.repeated) { 363 if (inner?.mapEntry) { 364 return new MapTraversal(inner); 365 } 366 return new ListTraversal(field); 367 } 368 return inner ? new MessageTraversal(inner) : undefined; 369 } 370 371 visitIndex(): TraversalContext | undefined { 372 // Messages are not lists, can't descend into an indexed element. 373 return undefined; 374 } 375 } 376 377 378 // ListTraversal implements TraversalContext using a repeated field as a source. 379 // 380 // It can descend into the individual list element. 381 class ListTraversal implements TraversalContext { 382 constructor(readonly field: prpc.Field) {} 383 384 completion(): Completion { 385 return { 386 fields: [], 387 values: () => fieldValues(this.field), 388 }; 389 } 390 391 visitField(): TraversalContext | undefined { 392 // Lists have no fields. 393 return undefined; 394 } 395 396 visitIndex(): TraversalContext | undefined { 397 const inner = this.field.message; 398 return inner ? new MessageTraversal(inner) : undefined; 399 } 400 } 401 402 403 // MapTraversal implements TraversalContext using a map entry as a source. 404 // 405 // It can descend into individual map values. 406 class MapTraversal implements TraversalContext { 407 constructor(readonly entry: prpc.Message) {} 408 409 completion(): Completion { 410 return { 411 // Can't enumerate map<...> keys, they are dynamic. 412 fields: [], 413 // All entries have the same type, can list possible values based on it. 414 values: () => { 415 // Note 'value' is a magical constant in map<...> descriptors. 416 const fieldObj = this.entry.fieldByJsonName('value'); 417 if (fieldObj) { 418 return fieldValues(fieldObj); 419 } 420 return []; 421 }, 422 }; 423 } 424 425 visitField(): TraversalContext | undefined { 426 const field = this.entry.fieldByJsonName('value'); 427 if (!field) { 428 return undefined; 429 } 430 const inner = field.message; 431 return inner ? new MessageTraversal(inner) : undefined; 432 } 433 434 visitIndex(): TraversalContext | undefined { 435 // Maps are not lists, can't descend into an indexed element. 436 return undefined; 437 } 438 } 439 440 441 // Helper for collecting possible values of a given field. 442 // 443 // Supports only enum-valued fields currently, since we can enumerate them. 444 const fieldValues = (field: prpc.Field) : Value[] => { 445 const enumObj = field.enum; 446 if (!enumObj) { 447 return []; 448 } 449 return enumObj.values.map((val) => { 450 return { 451 value: val.name, 452 doc: val.doc, 453 }; 454 }); 455 }; 456 457 458 // Traverses the protobuf descriptors starting from `root` by following the 459 // given JSON field path, returning the resulting completion, if any. 460 export const completionForPath = ( 461 root: prpc.Message, 462 path: PathItem[]): Completion | undefined => { 463 let cur: TraversalContext | undefined; 464 for (const elem of path) { 465 switch (elem.kind) { 466 case 'root': 467 cur = new MessageTraversal(root); 468 break; 469 case 'obj': 470 if (!elem.key) { 471 return undefined; 472 } 473 cur = cur ? cur.visitField(elem.key.val) : undefined; 474 break; 475 case 'list': 476 cur = cur ? cur.visitIndex() : undefined; 477 break; 478 } 479 if (!cur) { 480 break; 481 } 482 } 483 return cur ? cur.completion() : undefined; 484 };