The editor now has context aware completions

This commit is contained in:
eelke 2026-10-22 17:52:37 +02:00
parent 1a1bbd0838
commit 960967c8a6
18 changed files with 1984 additions and 0 deletions

View file

@ -1,11 +1,13 @@
add_library(core STATIC
my_boost_assert_handler.cpp
SqlLexer.cpp
SqlContext.cpp
CsvWriter.cpp
BackupFormatModel.cpp
ExplainTreeModelItem.cpp
jsoncpp.cpp
SqlLexer.h
SqlContext.h
ScopeGuard.h
CsvWriter.h
BackupFormatModel.h

538
core/SqlContext.cpp Normal file
View file

@ -0,0 +1,538 @@
#include "SqlContext.h"
#include "SqlLexer.h"
#include <QSet>
#include <algorithm>
namespace {
struct Tok {
int start = 0;
int end = 0;
BasicTokenType type = BasicTokenType::None;
QString text;
QString upper; // only filled for Symbol tokens
bool isIdent() const
{
return type == BasicTokenType::QuotedIdentifier
|| (type == BasicTokenType::Symbol && !text.isEmpty() && !text[0].isDigit());
}
bool isSelf(QChar c) const { return type == BasicTokenType::Self && text.size() == 1 && text[0] == c; }
bool isKeyword(const char *kw) const { return type == BasicTokenType::Symbol && upper == QLatin1String(kw); }
};
const QSet<QString> &reservedWords()
{
static const QSet<QString> words = {
"SELECT", "FROM", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "OFFSET", "FETCH", "FOR",
"UNION", "INTERSECT", "EXCEPT", "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL",
"OUTER", "ON", "USING", "SET", "RETURNING", "VALUES", "WINDOW", "AS", "LATERAL", "WITH",
"INSERT", "INTO", "UPDATE", "DELETE", "ONLY", "TABLESAMPLE"
};
return words;
}
bool isReserved(const Tok &t)
{
return t.type == BasicTokenType::Symbol && reservedWords().contains(t.upper);
}
bool isSetOperator(const Tok &t)
{
return t.isKeyword("UNION") || t.isKeyword("INTERSECT") || t.isKeyword("EXCEPT");
}
QString unquoteIdent(const QString &text, bool &quoted)
{
quoted = false;
if (text.startsWith('"')) {
quoted = true;
QString s = text.mid(1);
if (s.endsWith('"'))
s.chop(1);
s.replace(QStringLiteral("\"\""), QStringLiteral("\""));
return s;
}
return text;
}
// Name as used for matching against the catalog
QString normalizedIdent(const Tok &t, bool *quotedOut = nullptr)
{
bool quoted;
QString s = unquoteIdent(t.text, quoted);
if (quotedOut)
*quotedOut = quoted;
return quoted ? s : s.toLower();
}
std::vector<Tok> tokenize(const QString &text)
{
std::vector<Tok> toks;
SqlLexer lexer(text, LexerState::Null);
for (;;) {
SqlToken t = lexer.nextBasicToken();
if (t.tokenType == BasicTokenType::End)
break;
if (t.length <= 0 && t.tokenType == BasicTokenType::None)
break;
if (t.tokenType == BasicTokenType::WhiteSpace || t.tokenType == BasicTokenType::NewLine)
continue;
Tok tok;
tok.start = t.startPos;
tok.end = t.startPos + t.length;
tok.type = t.tokenType;
tok.text = text.mid(t.startPos, t.length);
if (tok.type == BasicTokenType::Symbol)
tok.upper = tok.text.toUpper();
toks.push_back(std::move(tok));
}
return toks;
}
bool isUnterminatedQuote(const QString &s)
{
return s.startsWith('"') && (s.count('"') % 2 == 1);
}
bool isRelationClause(SqlClause c)
{
return c == SqlClause::From || c == SqlClause::Join || c == SqlClause::Update || c == SqlClause::InsertInto;
}
// Relations of a parent query are visible in sub queries inside these clauses
bool parentVisible(SqlClause c)
{
switch (c) {
case SqlClause::Select:
case SqlClause::Where:
case SqlClause::On:
case SqlClause::GroupBy:
case SqlClause::OrderBy:
case SqlClause::Having:
case SqlClause::Set:
case SqlClause::Returning:
case SqlClause::Values:
case SqlClause::InsertInto:
return true;
default:
return false;
}
}
class Analyzer {
public:
using Toks = std::vector<Tok>;
explicit Analyzer(const Toks &s) : S(s) {}
struct Level {
int begin = 0; // first token index inside the level
int end = 0; // one past last token index inside the level
int open = -1; // index of "(" or -1 for statement level
};
// Direct tokens of level: nested groups are skipped, but the "(" and ")" markers are kept
std::vector<int> directTokens(const Level &lv) const
{
std::vector<int> d;
int depth = 0;
for (int i = lv.begin; i < lv.end; ++i) {
if (S[i].isSelf('(')) {
if (depth == 0)
d.push_back(i);
++depth;
}
else if (S[i].isSelf(')')) {
if (depth > 0)
--depth;
if (depth == 0)
d.push_back(i);
}
else if (depth == 0)
d.push_back(i);
}
return d;
}
struct Segment {
std::vector<int> d; // direct tokens of the segment
int cursor = 0; // position in d of first token at or after the cursor
};
// The part of the level between set operators that contains token index 'cursorIdx'
Segment segment(const Level &lv, int cursorIdx) const
{
std::vector<int> d = directTokens(lv);
size_t dc = 0;
while (dc < d.size() && d[dc] < cursorIdx)
++dc;
size_t b = 0;
for (size_t i = 0; i < dc; ++i)
if (isSetOperator(S[d[i]]))
b = i + 1;
size_t e = d.size();
for (size_t i = dc; i < d.size(); ++i)
if (isSetOperator(S[d[i]])) {
e = i;
break;
}
Segment seg;
seg.d.assign(d.begin() + b, d.begin() + e);
seg.cursor = static_cast<int>(dc - b);
return seg;
}
SqlClause clauseAt(const Segment &seg) const
{
SqlClause clause = SqlClause::None;
for (int i = 0; i < seg.cursor; ++i) {
const Tok &t = S[seg.d[i]];
if (t.type != BasicTokenType::Symbol)
continue;
const QString &k = t.upper;
if (k == "SELECT") clause = SqlClause::Select;
else if (k == "FROM") clause = SqlClause::From;
else if (k == "JOIN") clause = SqlClause::Join;
else if (k == "ON" || k == "USING") clause = SqlClause::On;
else if (k == "WHERE") clause = SqlClause::Where;
else if (k == "GROUP") clause = SqlClause::GroupBy;
else if (k == "ORDER") clause = SqlClause::OrderBy;
else if (k == "HAVING") clause = SqlClause::Having;
else if (k == "SET") clause = SqlClause::Set;
else if (k == "UPDATE") clause = SqlClause::Update;
else if (k == "INTO") clause = SqlClause::InsertInto;
else if (k == "RETURNING") clause = SqlClause::Returning;
else if (k == "VALUES") clause = SqlClause::Values;
else if (k == "LIMIT" || k == "OFFSET" || k == "FETCH" || k == "WINDOW" || k == "WITH")
clause = SqlClause::None;
}
return clause;
}
// True when the token before the cursor allows a new relation name
bool relationExpected(const Segment &seg) const
{
if (seg.cursor == 0)
return true;
const Tok &p = S[seg.d[seg.cursor - 1]];
return p.type == BasicTokenType::Comma
|| p.isSelf('(')
|| p.isKeyword("FROM") || p.isKeyword("JOIN") || p.isKeyword("UPDATE")
|| p.isKeyword("INTO") || p.isKeyword("ONLY") || p.isKeyword("LATERAL");
}
// Parse one relation item starting at position d in seg; returns the position after it
int parseItem(const Segment &seg, int d, std::vector<RelationRef> &out) const
{
const int n = static_cast<int>(seg.d.size());
auto tok = [&](int i) -> const Tok & { return S[seg.d[i]]; };
while (d < n && (tok(d).isKeyword("ONLY") || tok(d).isKeyword("LATERAL")))
++d;
if (d >= n)
return d;
RelationRef ref;
if (tok(d).isSelf('(')) {
ref.derived = true;
++d;
if (d < n && tok(d).isSelf(')'))
++d;
}
else if (tok(d).isIdent() && !isReserved(tok(d))) {
std::vector<const Tok *> parts{ &tok(d) };
++d;
while (d + 1 < n && tok(d).isSelf('.') && tok(d + 1).isIdent()) {
parts.push_back(&tok(d + 1));
d += 2;
}
ref.name = normalizedIdent(*parts.back(), &ref.quoted);
if (parts.size() >= 2)
ref.schema = normalizedIdent(*parts[parts.size() - 2]);
}
else
return d;
if (d < n && tok(d).isKeyword("AS"))
++d;
if (d < n && tok(d).isIdent() && !isReserved(tok(d))) {
ref.alias = normalizedIdent(tok(d));
++d;
}
if (d < n && tok(d).isSelf('(') && ref.derived == false) {
// alias column list or INSERT column list, leave to caller; skip only after an alias
if (!ref.alias.isEmpty() && d + 1 < n && tok(d + 1).isSelf(')'))
d += 2;
}
if (ref.derived) {
if (ref.alias.isEmpty())
return d;
ref.name = ref.alias;
}
out.push_back(std::move(ref));
return d;
}
void collectRelations(const Segment &seg, std::vector<RelationRef> &out) const
{
const int n = static_cast<int>(seg.d.size());
bool hasSelect = false;
for (int i = 0; i < n; ++i)
if (S[seg.d[i]].isKeyword("SELECT"))
hasSelect = true;
bool inFrom = false;
int d = 0;
while (d < n) {
const Tok &t = S[seg.d[d]];
if (t.isKeyword("FROM") || t.isKeyword("JOIN") || t.isKeyword("UPDATE")) {
inFrom = true;
d = parseItem(seg, d + 1, out);
}
else if (t.isKeyword("INTO") && d > 0 && S[seg.d[d - 1]].isKeyword("INSERT")) {
inFrom = true;
if (!hasSelect)
d = parseItem(seg, d + 1, out);
else {
std::vector<RelationRef> dummy;
d = parseItem(seg, d + 1, dummy);
}
}
else if (t.type == BasicTokenType::Comma && inFrom) {
d = parseItem(seg, d + 1, out);
}
else {
if (t.type == BasicTokenType::Symbol && reservedWords().contains(t.upper))
inFrom = false;
++d;
}
}
}
// Names of common table expressions defined by the WITH clause of the statement
void collectCtes(std::vector<RelationRef> &out) const
{
Level top{ 0, static_cast<int>(S.size()), -1 };
std::vector<int> d = directTokens(top);
size_t i = 0;
if (d.empty() || !S[d[0]].isKeyword("WITH"))
return;
i = 1;
if (i < d.size() && S[d[i]].isKeyword("RECURSIVE"))
++i;
while (i < d.size()) {
const Tok &nameTok = S[d[i]];
if (!nameTok.isIdent() || isReserved(nameTok))
return;
RelationRef ref;
ref.name = normalizedIdent(nameTok, &ref.quoted);
ref.derived = true;
++i;
if (i < d.size() && S[d[i]].isSelf('(')) { // column list
++i;
if (i < d.size() && S[d[i]].isSelf(')'))
++i;
}
out.push_back(std::move(ref));
if (i >= d.size() || !S[d[i]].isKeyword("AS"))
return;
++i;
while (i < d.size() && (S[d[i]].isKeyword("NOT") || S[d[i]].isKeyword("MATERIALIZED")))
++i;
if (i < d.size() && S[d[i]].isSelf('(')) {
++i;
if (i < d.size() && S[d[i]].isSelf(')'))
++i;
}
else
return;
if (i < d.size() && S[d[i]].type == BasicTokenType::Comma)
++i;
else
return;
}
}
const Toks &S;
};
} // namespace
SqlContext analyzeSqlContext(const QString &text, int cursorPos)
{
SqlContext ctx;
cursorPos = std::clamp(cursorPos, 0, static_cast<int>(text.size()));
ctx.prefixStart = cursorPos;
// An unterminated quoted identifier before the cursor would swallow the remaining text
QString work = text;
{
std::vector<Tok> head = tokenize(text.left(cursorPos));
if (!head.empty() && head.back().end == cursorPos && isUnterminatedQuote(head.back().text))
work.insert(cursorPos, QLatin1Char('"'));
}
std::vector<Tok> T = tokenize(work);
// Inside comments and strings nothing is completed
for (const Tok &t : T) {
if (t.start < cursorPos && cursorPos <= t.end) {
switch (t.type) {
case BasicTokenType::Comment:
case BasicTokenType::BlockComment:
case BasicTokenType::OpenBlockComment:
case BasicTokenType::QuotedString:
case BasicTokenType::DollarQuote:
return ctx;
default:
break;
}
}
else if (t.type == BasicTokenType::OpenBlockComment && t.start < cursorPos)
return ctx;
}
// Prefix token
int prefIdx = -1;
int cut = static_cast<int>(T.size());
for (int i = 0; i < static_cast<int>(T.size()); ++i) {
const Tok &t = T[i];
if (t.start < cursorPos && cursorPos <= t.end && t.isIdent()) {
prefIdx = i;
cut = i;
break;
}
if (t.start >= cursorPos) {
cut = i;
break;
}
}
if (prefIdx >= 0) {
const Tok &t = T[prefIdx];
QString typed = text.mid(t.start, cursorPos - t.start);
ctx.prefixStart = t.start;
ctx.prefixLen = cursorPos - t.start;
ctx.prefix = unquoteIdent(typed, ctx.prefixWasQuoted);
}
// Qualifier: [schema .] qualifier .
int qualStart = cut;
if (cut >= 2 && T[cut - 1].isSelf('.') && T[cut - 2].isIdent()) {
ctx.qualifier = normalizedIdent(T[cut - 2]);
qualStart = cut - 2;
if (cut >= 4 && T[cut - 3].isSelf('.') && T[cut - 4].isIdent()) {
ctx.qualifierSchema = normalizedIdent(T[cut - 4]);
qualStart = cut - 4;
}
}
const int removedEnd = prefIdx >= 0 ? cut + 1 : cut; // one past last removed token
// Statement bounds
int sb = 0;
for (int i = qualStart - 1; i >= 0; --i)
if (T[i].isSelf(';')) {
sb = i + 1;
break;
}
int se = static_cast<int>(T.size());
for (int i = removedEnd; i < static_cast<int>(T.size()); ++i)
if (T[i].isSelf(';')) {
se = i;
break;
}
std::vector<Tok> S;
for (int i = sb; i < se; ++i)
if (i < qualStart || i >= removedEnd)
S.push_back(T[i]);
const int cur = qualStart - sb; // index in S of first token after the cursor
Analyzer an(S);
// Enclosing paren groups
std::vector<int> opens;
for (int i = 0; i < cur; ++i) {
if (S[i].isSelf('('))
opens.push_back(i);
else if (S[i].isSelf(')') && !opens.empty())
opens.pop_back();
}
auto levelFor = [&](int open) {
Analyzer::Level lv;
lv.open = open;
lv.begin = open + 1;
lv.end = static_cast<int>(S.size());
if (open >= 0) {
int depth = 1;
for (int i = open + 1; i < static_cast<int>(S.size()); ++i) {
if (S[i].isSelf('(')) ++depth;
else if (S[i].isSelf(')') && --depth == 0) {
lv.end = i;
break;
}
}
}
else
lv.begin = 0;
return lv;
};
// Levels from innermost to outermost
std::vector<Analyzer::Level> levels;
for (int i = static_cast<int>(opens.size()) - 1; i >= 0; --i)
levels.push_back(levelFor(opens[i]));
levels.push_back(levelFor(-1));
SqlClause clause = SqlClause::None;
bool relExpected = false;
bool clauseResolved = false;
bool visible = true;
for (size_t li = 0; li < levels.size(); ++li) {
const Analyzer::Level &lv = levels[li];
const int cursorInLevel = li == 0 ? cur : levels[li - 1].open;
Analyzer::Segment seg = an.segment(lv, cursorInLevel);
const SqlClause lc = an.clauseAt(seg);
// a level without clause keyword is a parenthesised expression and inherits the clause of its parent
if (!clauseResolved && lc != SqlClause::None) {
clause = lc;
relExpected = isRelationClause(lc) && an.relationExpected(seg);
clauseResolved = true;
}
if (li >= 1)
visible = visible && parentVisible(lc);
if (visible)
an.collectRelations(seg, ctx.relations);
}
an.collectCtes(ctx.relations);
ctx.clause = clause;
const bool hasQualifier = !ctx.qualifier.isEmpty();
switch (clause) {
case SqlClause::Select:
case SqlClause::On:
case SqlClause::Where:
case SqlClause::GroupBy:
case SqlClause::OrderBy:
case SqlClause::Having:
case SqlClause::Set:
case SqlClause::Returning:
case SqlClause::Values:
ctx.kind = hasQualifier ? CompletionKind::QualifiedColumn : CompletionKind::Column;
break;
case SqlClause::From:
case SqlClause::Join:
case SqlClause::Update:
case SqlClause::InsertInto:
if (hasQualifier)
ctx.kind = CompletionKind::Relation;
else if (relExpected)
ctx.kind = clause == SqlClause::Join ? CompletionKind::JoinTarget : CompletionKind::Relation;
else if (clause == SqlClause::InsertInto && !levels.empty() && levels[0].open >= 0)
ctx.kind = CompletionKind::Column;
break;
default:
break;
}
return ctx;
}

70
core/SqlContext.h Normal file
View file

@ -0,0 +1,70 @@
#ifndef SQLCONTEXT_H
#define SQLCONTEXT_H
#include <QString>
#include <vector>
/**
* Kind of names that are expected at the cursor position.
*/
enum class CompletionKind {
None,
Relation, // schema, table or view name
Column, // column name of the relations in scope
QualifiedColumn, // column after "alias." or "table."
Schema,
Function,
JoinTarget // complete "table alias ON ..." proposal after JOIN
};
/**
* Part of the statement the cursor is in.
*/
enum class SqlClause {
None,
Select,
From,
Join,
On,
Where,
GroupBy,
OrderBy,
Having,
Set,
Update,
InsertInto,
Returning,
Values
};
/**
* An item of a FROM clause (or UPDATE/INSERT target, CTE, derived table).
* Names are normalised: unquoted identifiers are folded to lower case.
*/
struct RelationRef {
QString schema;
QString name; // may be a CTE name or the alias of a derived table
QString alias;
bool quoted = false; // name was written as quoted identifier
bool derived = false; // CTE or sub query, columns are not in the catalog
};
struct SqlContext {
CompletionKind kind = CompletionKind::None;
SqlClause clause = SqlClause::None;
QString prefix; // identifier text typed so far (without opening quote)
int prefixStart = 0; // replace range in document
int prefixLen = 0;
QString qualifier; // text before '.', if any
QString qualifierSchema; // for schema.table.
std::vector<RelationRef> relations; // relations in scope, innermost query first
bool prefixWasQuoted = false;
};
/**
* Tolerant analysis of possibly incomplete SQL around the cursor.
* @param cursorPos position in text (between characters) where completion is requested.
*/
SqlContext analyzeSqlContext(const QString &text, int cursorPos);
#endif // SQLCONTEXT_H