fix(mongo): stop misattributing helper lines in command diagnostics

Closes #11279
This commit is contained in:
弈秋
2026-10-09 16:46:00 +08:00
committed by GitHub
parent 3d87ea5797
commit 274a944fa2
3 changed files with 71 additions and 19 deletions
+45 -18
View File
@@ -1455,25 +1455,26 @@ function splitMongoSegmentAtSoftStarts(segment: MongoTextRange): MongoTextRange[
for (let index = 1; index < boundaries.length; index += 1) {
const boundary = boundaries[index] ?? 0;
const candidate = trimMongoOuterCommentRange(segment.text, start, boundary);
// Only accept newline-based splitting when every slice is a valid command;
// otherwise keep the original text intact and let normal parsing reject it.
if (!candidate || !parseMongoCommand(candidate.text)) return [segment];
segments.push({
from: segment.from + candidate.from,
to: segment.from + candidate.to,
text: candidate.text,
});
if (candidate) pushMongoSlice(segments, segment, candidate);
start = boundary;
}
const last = trimMongoOuterCommentRange(segment.text, start, segment.text.length);
if (!last || !parseMongoCommand(last.text)) return [segment];
segments.push({
from: segment.from + last.from,
to: segment.from + last.to,
text: last.text,
});
return segments;
if (last) pushMongoSlice(segments, segment, last);
// Splitting is an improvement only when at least one slice really is a command;
// otherwise keep the text intact so the normal parser reports the failure once
// instead of once per line.
return segments.some((slice) => isMongoCommandText(slice.text)) && segments.length > 1 ? segments : [segment];
}
function pushMongoSlice(segments: MongoTextRange[], parent: MongoTextRange, slice: MongoTextRange): void {
segments.push({ from: parent.from + slice.from, to: parent.from + slice.to, text: slice.text });
}
/** Does this text start like a shell command (`db…`, `use …`, `show dbs`)? */
export function isMongoCommandText(text: string): boolean {
const trimmed = text.trimStart();
return isMongoCommandLineStart(trimmed, 0) || /^rs[.\[]|^sh[.\[]/i.test(trimmed);
}
function mongoTopLevelCommandLineStarts(segment: string): number[] {
@@ -1485,15 +1486,26 @@ function mongoTopLevelCommandLineStarts(segment: string): number[] {
let blockComment = false;
let lineStart = 0;
let firstNonWhitespaceOnLine = -1;
// Does the line we just finished close a statement? Used so a JS helper line
// between two commands (`var ids = [...]`) still starts its own statement instead
// of being glued onto the command above it.
let lineHadCode = false;
let closesStatement = false;
let afterClosedStatement = false;
for (let i = 0; i < segment.length; i += 1) {
const char = segment[i] ?? "";
const next = segment[i + 1] ?? "";
if (char === "\n") {
// A lone CR is a line break too (pasted shell history, old Mac files); CRLF is
// handled by the LF branch once the CR falls inside the same line.
if (char === "\n" || (char === "\r" && next !== "\n")) {
if (lineComment) lineComment = false;
afterClosedStatement = depth === 0 && lineHadCode && closesStatement;
lineStart = i + 1;
firstNonWhitespaceOnLine = -1;
lineHadCode = false;
closesStatement = false;
continue;
}
@@ -1530,16 +1542,31 @@ function mongoTopLevelCommandLineStarts(segment: string): number[] {
if (char === '"' || char === "'" || char === "`") {
if (firstNonWhitespaceOnLine === -1 && !/\s/.test(char)) firstNonWhitespaceOnLine = i;
lineHadCode = true;
closesStatement = false;
quote = char;
continue;
}
if (/\s/.test(char)) continue;
if (char === "{" || char === "[" || char === "(") depth += 1;
else if ((char === "}" || char === "]" || char === ")") && depth > 0) depth -= 1;
else if (char === "}" || char === "]" || char === ")") {
if (depth > 0) depth -= 1;
closesStatement = depth === 0;
} else if (char === ";" && depth === 0) {
closesStatement = true;
} else {
closesStatement = false;
}
lineHadCode = true;
if (firstNonWhitespaceOnLine === -1 && !/\s/.test(char)) {
firstNonWhitespaceOnLine = i;
if (depth === 0 && char !== "." && isMongoCommandLineStart(segment, i)) starts.push(i);
if (depth === 0 && char !== "." && (isMongoCommandLineStart(segment, i) || afterClosedStatement)) {
starts.push(i);
afterClosedStatement = false;
}
}
}
@@ -9,7 +9,7 @@
import type { SqlTextSpan } from "@/types/database";
import type { SqlSemanticDiagnostic } from "@/lib/sql/semantic/diagnostics";
import { findMatchingParen, hasUnclosedMongoDelimiters, trimMongoOuterComments } from "@dbx-app/mongo-shell";
import { bulkWriteFilters, describeMongoCommandParseFailure, mongoFilterIsEffectivelyUnbounded, parseMongoCommand, splitMongoCommandTextRanges, type MongoCommand, type MongoTextRange } from "@/lib/mongo/mongoShellCommand";
import { bulkWriteFilters, describeMongoCommandParseFailure, isMongoCommandText, mongoFilterIsEffectivelyUnbounded, parseMongoCommand, splitMongoCommandTextRanges, type MongoCommand, type MongoTextRange } from "@/lib/mongo/mongoShellCommand";
const UNCLOSED_MESSAGE = "MongoDB command has unclosed parentheses, brackets, braces, or strings.";
@@ -24,10 +24,15 @@ export function buildMongoSyntaxDiagnostics(source: string, cursor = -1): SqlSem
const diagnostics: SqlSemanticDiagnostic[] = [];
const segments = splitMongoCommandTextRanges(source);
const current = segmentAtCursor(segments, cursor, source.length);
// Shell users keep JS helper statements between commands (`var ids = [...]` feeding a
// later `$in`). Those lines are not commands dbx can validate, and trying to diagnose
// them is what made the command above them carry a bogus "Unexpected text after find(...)".
const hasCommand = segments.some((segment) => isMongoCommandText(trimMongoOuterComments(segment.text)));
for (const segment of segments) {
const text = trimMongoOuterComments(segment.text).trim().replace(/;$/, "").trim();
if (!text) continue;
if (hasCommand && !isMongoCommandText(text)) continue;
if (hasUnclosedMongoDelimiters(text)) {
// A command still being typed under the cursor is not an error yet.
+20
View File
@@ -19,6 +19,26 @@ test("valid commands and comments produce no diagnostics", () => {
}
});
test("a JS helper line between commands does not blame the command above it", () => {
// Shell users build a big `$in` list in a variable, and the editor must not turn
// that into "Unexpected text after find(...)" on the previous, correct command.
const helper = underlined('db.getCollection("a").find({ x: 1 })\nvar ids = [1, 2, 3]\ndb.getCollection("b").find({ _id: { $in: ids } })');
assert.ok(!helper.some((d) => /Unexpected text after/.test(d.message)), JSON.stringify(helper));
assert.equal(helper.length, 1, JSON.stringify(helper));
// What is left is the honest complaint about the third line: dbx cannot evaluate `ids`.
assert.match(helper[0]!.message, /filter argument of find\(\) is not a valid document/);
assert.equal(helper[0]!.text, 'db.getCollection("b").find');
// A `print()` between two commands is not a command either, so both stay clean.
assert.deepEqual(buildMongoSyntaxDiagnostics('db.a.find({ x: 1 })\nprint("done")\ndb.b.find({ y: 2 })'), []);
// Same for a lone CR separating commands (pasted shell history).
assert.deepEqual(buildMongoSyntaxDiagnostics('db.a.find({ x: 1 })\rdb.b.find({ y: 2 })'), []);
// A real error on a later line is still reported, on that line and about that command.
const [late] = underlined("db.a.find({})\nvar ids = [1]\ndb.b.foobar({})");
assert.match(late!.message, /Collection method foobar\(\) is not supported/);
});
test("underlines the offending value constructor and names the argument", () => {
const [single] = underlined('db.reports.find({id: Foo("x")})');
assert.equal(single?.severity, "error");