Files
ragflow/internal/cli/parser.go
Jake Armstrong 93d3deb5e4 Fix admin CLI system variable commands (#14956)
## What

Fixes #12409.

Implements admin CLI support for:

- `list vars;`
- `show var <name-or-prefix>;`
- `set var <name> <value>;`

## Changes

- Wire Go CLI variable commands to the admin API.
- Support integer and quoted string values in `SET VAR`.
- Return variable rows as `data_type`, `name`, `setting_type`, and
`value`.
- Add exact-name lookup with prefix fallback for `SHOW VAR`.
- Validate values by stored data type: `string`, `integer`, `bool`, and
`json`.
- Keep the legacy Python admin CLI/server behavior aligned.
- Update admin CLI docs and add focused tests.

## Verification

- `go test -count=1 ./internal/cli`
- `python3.12 -m py_compile admin/server/services.py
admin/server/routes.py api/db/services/system_settings_service.py
admin/client/parser.py admin/client/ragflow_client.py`
- Python admin CLI parser smoke test for `SET VAR`, quoted values, `SHOW
VAR`, and `LIST VARS`.
- Attempted `./run_go_tests.sh`; local environment is missing native
tokenizer/linker artifacts:
  - `internal/cpp/cmake-build-release/librag_tokenizer_c_api.a`
  - `-lstdc++`

Co-authored-by: Jin Hai <haijin.chn@gmail.com>
2026-05-18 19:08:45 +08:00

566 lines
15 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package cli
import (
"fmt"
"math"
"strconv"
"strings"
)
// Parser implements a recursive descent parser for RAGFlow CLI commands
type Parser struct {
lexer *Lexer
curToken Token
peekToken Token
}
// NewParser creates a new parser
func NewParser(input string) *Parser {
l := NewLexer(input)
p := &Parser{lexer: l}
// Read two tokens to initialize curToken and peekToken
p.nextToken()
p.nextToken()
return p
}
func (p *Parser) nextToken() {
p.curToken = p.peekToken
p.peekToken = p.lexer.NextToken()
}
// Parse parses the input and returns a Command
func (p *Parser) Parse(adminCommand bool) (*Command, error) {
if p.curToken.Type == TokenEOF {
return nil, nil
}
// Check for meta commands (backslash commands)
if p.curToken.Type == TokenIdentifier && strings.HasPrefix(p.curToken.Value, "\\") {
return p.parseMetaCommand()
}
// Check for ContextEngine commands (ls, cat, search)
// Note: These are now handled in parseUserCommand to support both SQL-style and CE-style syntax
// if p.curToken.Type == TokenIdentifier && isCECommand(p.curToken.Value) {
// return p.parseCECommand()
// }
return p.parseCommand(adminCommand)
}
func (p *Parser) parseMetaCommand() (*Command, error) {
cmd := NewCommand("meta")
cmdName := strings.TrimPrefix(p.curToken.Value, "\\")
cmd.Params["command"] = strings.ToLower(cmdName)
// Parse arguments
var args []string
p.nextToken()
for p.curToken.Type != TokenEOF {
args = append(args, p.curToken.Value)
p.nextToken()
}
cmd.Params["args"] = args
return cmd, nil
}
func (p *Parser) parseAdminCommand() (*Command, error) {
switch p.curToken.Type {
case TokenLogin:
return p.parseAdminLoginUser()
case TokenLogout:
return p.parseAdminLogout()
case TokenPing:
return p.parseAdminPingServer()
case TokenList:
return p.parseAdminListCommand()
case TokenShow:
return p.parseAdminShowCommand()
case TokenCreate:
return p.parseAdminCreateCommand()
case TokenDrop:
return p.parseAdminDropCommand()
case TokenAlter:
return p.parseAdminAlterCommand()
case TokenGrant:
return p.parseAdminGrantCommand()
case TokenRevoke:
return p.parseAdminRevokeCommand()
case TokenSet:
return p.parseAdminSetCommand()
case TokenUnset:
return p.parseAdminUnsetCommand()
case TokenReset:
return p.parseAdminResetCommand()
case TokenGenerate:
return p.parseAdminGenerateCommand()
case TokenImport:
return p.parseAdminImportCommand()
case TokenSearch:
return p.parseAdminSearchCommand()
case TokenParse:
return p.parseAdminParseCommand()
case TokenBenchmark:
return p.parseAdminBenchmarkCommand()
case TokenRegister:
return p.parseAdminRegisterCommand()
case TokenStartup:
return p.parseAdminStartupCommand()
case TokenShutdown:
return p.parseAdminShutdownCommand()
case TokenRestart:
return p.parseAdminRestartCommand()
default:
return nil, fmt.Errorf("unknown command: %s", p.curToken.Value)
}
}
func (p *Parser) parseUserCommand() (*Command, error) {
switch p.curToken.Type {
case TokenLogin:
return p.parseLoginUser()
case TokenLogout:
return p.parseLogout()
case TokenPing:
return p.parsePingServer()
case TokenList:
return p.parseListCommand()
case TokenShow:
return p.parseShowCommand()
case TokenCreate:
return p.parseCreateCommand()
case TokenDrop:
return p.parseDropCommand()
case TokenAdd:
return p.parseAddCommand()
case TokenDelete:
return p.parseDeleteCommand()
case TokenAlter:
return p.parseAlterCommand()
case TokenGrant:
return p.parseGrantCommand()
case TokenRevoke:
return p.parseRevokeCommand()
case TokenSet:
return p.parseSetCommand()
case TokenUnset:
return p.parseUnsetCommand()
case TokenReset:
return p.parseResetCommand()
case TokenGenerate:
return p.parseGenerateCommand()
case TokenImport:
return p.parseImportCommand()
case TokenInsert:
return p.parseInsertCommand()
case TokenSearch:
return p.parseSearchCommand()
case TokenParse:
return p.parseParseCommand()
case TokenBenchmark:
return p.parseBenchmarkCommand()
case TokenRegister:
return p.parseRegisterCommand()
case TokenStartup:
return p.parseStartupCommand()
case TokenShutdown:
return p.parseShutdownCommand()
case TokenRestart:
return p.parseRestartCommand()
case TokenEnable:
return p.parseEnableCommand()
case TokenDisable:
return p.parseDisableCommand()
case TokenStream:
return p.parseStreamCommand()
case TokenChat:
return p.parseChatCommand()
case TokenThink:
return p.parseThinkCommand()
case TokenEmbed:
return p.parseEmbedCommand()
case TokenRerank:
return p.parseRerankCommand()
case TokenASR:
return p.parseASRCommand()
case TokenTTS:
return p.parseTTSCommand()
case TokenOCR:
return p.parseOCRCommand()
case TokenCheck:
return p.parseCheckCommand()
case TokenLS:
return p.parseCEListCommand()
case TokenCat:
return p.parseCECatCommand()
case TokenUse:
return p.parseUseCommand()
case TokenUpdate:
return p.parseUpdateCommand()
case TokenRemove:
return p.parseRemoveCommand()
default:
return nil, fmt.Errorf("unknown command: %s", p.curToken.Value)
}
}
func (p *Parser) parseCommand(adminCommand bool) (*Command, error) {
if p.curToken.Type != TokenIdentifier && !isKeyword(p.curToken.Type) {
return nil, fmt.Errorf("expected command, got %s", p.curToken.Value)
}
if adminCommand {
return p.parseAdminCommand()
}
return p.parseUserCommand()
}
func (p *Parser) expectPeek(tokenType int) error {
if p.peekToken.Type != tokenType {
return fmt.Errorf("expected %s, got %s", tokenTypeToString(tokenType), p.peekToken.Value)
}
p.nextToken()
return nil
}
func (p *Parser) expectSemicolon() error {
if p.curToken.Type == TokenSemicolon {
return nil
}
if p.peekToken.Type == TokenSemicolon {
p.nextToken()
return nil
}
return fmt.Errorf("expected semicolon")
}
func isKeyword(tokenType int) bool {
return tokenType >= TokenLogin && tokenType <= TokenTag
}
// isCECommand checks if the given string is a Filesystem command
func isCECommand(s string) bool {
upper := strings.ToUpper(s)
switch upper {
case "LS", "LIST", "SEARCH":
return true
}
return false
}
// Helper functions for parsing
func (p *Parser) parseQuotedString() (string, error) {
if p.curToken.Type != TokenQuotedString {
return "", fmt.Errorf("expected quoted string, got %s", p.curToken.Value)
}
return p.curToken.Value, nil
}
func (p *Parser) parseIdentifier() (string, error) {
if p.curToken.Type != TokenIdentifier {
return "", fmt.Errorf("expected identifier, got %s", p.curToken.Value)
}
return p.curToken.Value, nil
}
func (p *Parser) parseVariableValue() (string, error) {
switch p.curToken.Type {
case TokenIdentifier, TokenQuotedString, TokenInteger, TokenFloat:
return p.curToken.Value, nil
default:
return "", fmt.Errorf("expected variable value, got %s", p.curToken.Value)
}
}
func (p *Parser) parseNumber() (int, error) {
if p.curToken.Type != TokenInteger {
return 0, fmt.Errorf("expected number, got %s", p.curToken.Value)
}
return strconv.Atoi(p.curToken.Value)
}
func (p *Parser) parseFloat() (float64, error) {
if p.curToken.Type != TokenInteger {
return math.NaN(), fmt.Errorf("expected number, got %s", p.curToken.Value)
}
result, err := strconv.ParseFloat(p.curToken.Value, 64)
if err != nil {
return math.NaN(), err
}
return result, nil
}
func tokenTypeToString(t int) string {
// Simplified for error messages
return fmt.Sprintf("token(%d)", t)
}
// parseCECommand parses ContextEngine commands (ls, search)
func (p *Parser) parseCECommand() (*Command, error) {
cmdName := strings.ToUpper(p.curToken.Value)
switch cmdName {
case "LS", "LIST":
return p.parseCEListCommand()
case "CAT":
return p.parseCECatCommand()
case "SEARCH":
return p.parseCESearchCommand()
default:
return nil, fmt.Errorf("unknown ContextEngine command: %s", cmdName)
}
}
// parseCEListCommand parses the ls command
// Syntax: ls [path] or ls datasets
func (p *Parser) parseCEListCommand() (*Command, error) {
p.nextToken() // consume LS/LIST
cmd := NewCommand("ce_ls")
// Check if there's a path argument
// Also accept TokenDatasets since "datasets" is a keyword but can be a path
if p.curToken.Type == TokenIdentifier || p.curToken.Type == TokenQuotedString ||
p.curToken.Type == TokenDatasets {
path := p.curToken.Value
// Remove quotes if present
if p.curToken.Type == TokenQuotedString {
path = strings.Trim(path, "\"'")
}
p.nextToken()
// Handle path components separated by slashes (e.g., "skills/hub1")
for p.curToken.Type == TokenSlash {
p.nextToken() // consume slash
if p.curToken.Type == TokenIdentifier || p.curToken.Type == TokenDatasets ||
p.curToken.Type == TokenAgents || p.curToken.Type == TokenChats {
path = path + "/" + p.curToken.Value
p.nextToken()
} else if p.curToken.Type == TokenNumber {
// Handle version numbers like 1.0.0 (parsed as number . number . number)
// OR filenames starting with numbers like 3_list_compressors.pdf
numberPart := p.curToken.Value
p.nextToken()
// Continue reading .number parts (version number format)
if p.curToken.Type == TokenIllegal && p.curToken.Value == "." {
versionPart := numberPart
for p.curToken.Type == TokenIllegal && p.curToken.Value == "." {
p.nextToken() // consume .
if p.curToken.Type == TokenNumber {
versionPart = versionPart + "." + p.curToken.Value
p.nextToken()
} else {
break
}
}
path = path + "/" + versionPart
} else if p.curToken.Type == TokenIdentifier {
// Filename starting with number: 3_list_compressors.pdf
path = path + "/" + numberPart + p.curToken.Value
p.nextToken()
} else {
// Just a number
path = path + "/" + numberPart
}
} else {
// Trailing slash, just append it
path = path + "/"
break
}
}
cmd.Params["path"] = path
} else {
// Default to "datasets" root
cmd.Params["path"] = "datasets"
}
// Optional semicolon
if p.curToken.Type == TokenSemicolon {
p.nextToken()
}
return cmd, nil
}
// parseCECatCommand parses the cat command
// Syntax: cat <path>
func (p *Parser) parseCECatCommand() (*Command, error) {
p.nextToken() // consume CAT
cmd := NewCommand("ce_cat")
if p.curToken.Type != TokenIdentifier && p.curToken.Type != TokenQuotedString {
return nil, fmt.Errorf("expected path after CAT")
}
path := p.curToken.Value
if p.curToken.Type == TokenQuotedString {
path = strings.Trim(path, "\"'")
}
p.nextToken()
// Handle path components separated by slashes (e.g., "skills/hub1/skill/README.md")
for p.curToken.Type == TokenSlash {
p.nextToken() // consume slash
if p.curToken.Type == TokenIdentifier || p.curToken.Type == TokenAgents ||
p.curToken.Type == TokenChats || p.curToken.Type == TokenDatasets {
path = path + "/" + p.curToken.Value
p.nextToken()
} else if p.curToken.Type == TokenNumber {
// Handle version numbers like 1.0.0 (parsed as number . number . number)
// OR filenames starting with numbers like 3_list_compressors.pdf
numberPart := p.curToken.Value
p.nextToken()
// Continue reading .number parts (version number format)
if p.curToken.Type == TokenIllegal && p.curToken.Value == "." {
versionPart := numberPart
for p.curToken.Type == TokenIllegal && p.curToken.Value == "." {
p.nextToken() // consume .
if p.curToken.Type == TokenNumber {
versionPart = versionPart + "." + p.curToken.Value
p.nextToken()
} else {
break
}
}
path = path + "/" + versionPart
} else if p.curToken.Type == TokenIdentifier {
// Filename starting with number: 3_list_compressors.pdf
path = path + "/" + numberPart + p.curToken.Value
p.nextToken()
} else {
// Just a number
path = path + "/" + numberPart
}
} else if p.curToken.Type == TokenQuotedString {
path = path + "/" + strings.Trim(p.curToken.Value, "\"'")
p.nextToken()
} else {
// Trailing slash, just append it
path = path + "/"
break
}
}
cmd.Params["path"] = path
// Optional semicolon
if p.curToken.Type == TokenSemicolon {
p.nextToken()
}
return cmd, nil
}
// parseCESearchCommand parses the search command
// Syntax: search <query> or search <query> in <path>
func (p *Parser) parseCESearchCommand() (*Command, error) {
p.nextToken() // consume SEARCH
cmd := NewCommand("ce_search")
if p.curToken.Type != TokenIdentifier && p.curToken.Type != TokenQuotedString {
return nil, fmt.Errorf("expected query after SEARCH")
}
query := p.curToken.Value
if p.curToken.Type == TokenQuotedString {
query = strings.Trim(query, "\"'")
}
cmd.Params["query"] = query
p.nextToken()
// Check for optional "in <path>" clause
if p.curToken.Type == TokenIdentifier && strings.ToUpper(p.curToken.Value) == "IN" {
p.nextToken() // consume IN
if p.curToken.Type != TokenIdentifier && p.curToken.Type != TokenQuotedString {
return nil, fmt.Errorf("expected path after IN")
}
path := p.curToken.Value
if p.curToken.Type == TokenQuotedString {
path = strings.Trim(path, "\"'")
}
p.nextToken()
// Handle path components separated by slashes (e.g., "skills/hub1")
for p.curToken.Type == TokenSlash {
p.nextToken() // consume slash
if p.curToken.Type == TokenIdentifier || p.curToken.Type == TokenAgents ||
p.curToken.Type == TokenChats || p.curToken.Type == TokenDatasets {
path = path + "/" + p.curToken.Value
p.nextToken()
} else if p.curToken.Type == TokenNumber {
// Handle version numbers like 1.0.0 (parsed as number . number . number)
// OR filenames starting with numbers like 3_list_compressors.pdf
numberPart := p.curToken.Value
p.nextToken()
// Continue reading .number parts (version number format)
if p.curToken.Type == TokenIllegal && p.curToken.Value == "." {
versionPart := numberPart
for p.curToken.Type == TokenIllegal && p.curToken.Value == "." {
p.nextToken() // consume .
if p.curToken.Type == TokenNumber {
versionPart = versionPart + "." + p.curToken.Value
p.nextToken()
} else {
break
}
}
path = path + "/" + versionPart
} else if p.curToken.Type == TokenIdentifier {
// Filename starting with number: 3_list_compressors.pdf
path = path + "/" + numberPart + p.curToken.Value
p.nextToken()
} else {
// Just a number
path = path + "/" + numberPart
}
} else if p.curToken.Type == TokenQuotedString {
path = path + "/" + strings.Trim(p.curToken.Value, "\"'")
p.nextToken()
} else {
// Trailing slash, just append it
path = path + "/"
break
}
}
cmd.Params["path"] = path
} else {
cmd.Params["path"] = "."
}
// Optional semicolon
if p.curToken.Type == TokenSemicolon {
p.nextToken()
}
return cmd, nil
}