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  };