Skip to content

Commit a219a2b

Browse files
committed
feat: add warning on case discrepancies in SCG YAML file paths (W701)
Adds diagnostic W701 that warns when layout file names in the SCG YAML have different casing than the actual files on disk. This helps catch issues that would fail on case-sensitive file systems (Linux). Closes #944
1 parent 6db8f27 commit a219a2b

7 files changed

Lines changed: 239 additions & 25 deletions

File tree

packages/extension/client/src/protocol.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ export const fsReadFile = new RequestType<{ uri: string }, number[], unknown>(
8383
"septic/fs_readfile",
8484
);
8585

86+
export const fsReadDir = new RequestType<{ uri: string }, string[], unknown>(
87+
"septic/fs_readdir",
88+
);
89+
8690
export const findYamlFiles = new RequestType<object, string[], unknown>(
8791
"septic/findYamlFiles",
8892
);

packages/extension/client/src/requests.ts

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,33 @@ import { getSearchPattern } from "./util";
99
import { LanguageClient } from "vscode-languageclient/node";
1010

1111
export function registerRequestHandlers(client: LanguageClient) {
12-
client.onRequest(protocol.globFiles, async (params) => {
13-
const parsedUri = vscode.Uri.parse(params.uri);
14-
const folder = vscode.workspace.getWorkspaceFolder(parsedUri);
15-
if (!folder) {
16-
return [];
17-
}
18-
const pattern = getSearchPattern(folder.uri.path, parsedUri.path);
19-
const files = await vscode.workspace.findFiles(pattern);
20-
return files
21-
.filter((f) => f.fsPath.includes(folder.uri.fsPath))
22-
.map((f) => f.toString());
23-
});
12+
client.onRequest(protocol.globFiles, async (params) => {
13+
const parsedUri = vscode.Uri.parse(params.uri);
14+
const folder = vscode.workspace.getWorkspaceFolder(parsedUri);
15+
if (!folder) {
16+
return [];
17+
}
18+
const pattern = getSearchPattern(folder.uri.path, parsedUri.path);
19+
const files = await vscode.workspace.findFiles(pattern);
20+
return files
21+
.filter((f) => f.fsPath.includes(folder.uri.fsPath))
22+
.map((f) => f.toString());
23+
});
2424

25-
client.onRequest(
26-
protocol.fsReadFile,
27-
async (e): Promise<number[]> => {
28-
const uri = vscode.Uri.parse(e.uri);
29-
return Array.from(await vscode.workspace.fs.readFile(uri));
30-
}
31-
);
25+
client.onRequest(protocol.fsReadFile, async (e): Promise<number[]> => {
26+
const uri = vscode.Uri.parse(e.uri);
27+
return Array.from(await vscode.workspace.fs.readFile(uri));
28+
});
3229

33-
client.onRequest(protocol.findYamlFiles, async () => {
34-
return (await vscode.workspace.findFiles(`**/*.yaml`)).map((f) =>
35-
f.toString()
36-
);
37-
});
38-
}
30+
client.onRequest(protocol.fsReadDir, async (e): Promise<string[]> => {
31+
const uri = vscode.Uri.parse(e.uri);
32+
const entries = await vscode.workspace.fs.readDirectory(uri);
33+
return entries.map(([name]) => name);
34+
});
35+
36+
client.onRequest(protocol.findYamlFiles, async () => {
37+
return (await vscode.workspace.findFiles(`**/*.yaml`)).map((f) =>
38+
f.toString(),
39+
);
40+
});
41+
}

packages/extension/server/src/protocol.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export const fsReadFile = new RequestType<{ uri: string }, number[], unknown>(
4343
"septic/fs_readfile",
4444
);
4545

46+
export const fsReadDir = new RequestType<{ uri: string }, string[], unknown>(
47+
"septic/fs_readdir",
48+
);
49+
4650
export const findYamlFiles = new RequestType<object, string[], unknown>(
4751
"septic/findYamlFiles",
4852
);

packages/extension/server/src/server.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
Location,
1919
DidChangeWatchedFilesNotification,
2020
CompletionParams,
21+
Diagnostic,
22+
DiagnosticSeverity,
2123
} from "vscode-languageserver/node";
2224

2325
import { TextDocument } from "vscode-languageserver-textdocument";
@@ -37,9 +39,12 @@ import {
3739
SepticMetaInfoProvider,
3840
SepticCnfg,
3941
compareCnfgs,
42+
scgConfigFromYAML,
43+
SepticDiagnosticCode,
4044
} from "@equinor/septic-config-lib";
4145
import { getIgnorePatterns, getIgnoredCodes } from "./ignorePath";
4246
import { ContextManager } from "./contextManager";
47+
import { findCaseDiscrepancies } from "./util/caseCheck";
4348

4449
// Create a connection for the server, using Node's IPC as a transport.
4550
// Also include all preview / proposed LSP features.
@@ -97,6 +102,68 @@ async function publishDiagnosticsScgContext(
97102
});
98103

99104
await Promise.all(diagnosticsPromises);
105+
106+
await publishCaseDiscrepancyDiagnostics(context);
107+
}
108+
109+
async function publishCaseDiscrepancyDiagnostics(
110+
context: ScgContext,
111+
): Promise<void> {
112+
const yamlUri = context.filePath;
113+
const ignorePatterns = await getIgnorePatterns(connection, settingsManager);
114+
const codes = getIgnoredCodes(yamlUri, ignorePatterns);
115+
if (codes !== undefined && codes.length === 0) {
116+
return;
117+
}
118+
119+
const doc = await documentProvider.getDocument(yamlUri);
120+
if (!doc) {
121+
return;
122+
}
123+
124+
const text = doc.getText();
125+
let scgConfig;
126+
try {
127+
scgConfig = scgConfigFromYAML(text);
128+
} catch {
129+
return;
130+
}
131+
132+
const templatepath = scgConfig.templatepath;
133+
const templateDirUri = yamlUri.startsWith("file:")
134+
? new URL(templatepath + "/", new URL(".", new URL(yamlUri))).href
135+
: path.join(path.dirname(yamlUri), templatepath);
136+
137+
let dirEntries: string[];
138+
try {
139+
dirEntries = await connection.sendRequest(protocol.fsReadDir, {
140+
uri: templateDirUri,
141+
});
142+
} catch {
143+
return;
144+
}
145+
146+
const layoutNames = scgConfig.layout.map((l) => l.name);
147+
const discrepancies = findCaseDiscrepancies(layoutNames, dirEntries, text);
148+
149+
let diagnostics: Diagnostic[] = discrepancies.map((d) => ({
150+
severity: DiagnosticSeverity.Warning,
151+
range: {
152+
start: doc.positionAt(d.offset),
153+
end: doc.positionAt(d.offset + d.fileName.length),
154+
},
155+
message: `Case discrepancy in file path: '${d.fileName}' does not match actual file '${d.actualName}'. This will fail on case-sensitive file systems (Linux).`,
156+
code: SepticDiagnosticCode.caseDiscrepancyPath,
157+
source: "septic",
158+
}));
159+
160+
if (codes) {
161+
diagnostics = diagnostics.filter(
162+
(diag) => diag.code && !codes.includes(diag.code as string),
163+
);
164+
}
165+
166+
connection.sendDiagnostics({ uri: yamlUri, diagnostics: diagnostics });
100167
}
101168

102169
async function publishDiagnosticsCnfg(cnfg: SepticCnfg): Promise<void> {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, it } from "mocha";
2+
import { expect } from "chai";
3+
import { findCaseDiscrepancies, findLayoutNameOffset } from "../util/caseCheck";
4+
5+
describe("Test findLayoutNameOffset", () => {
6+
it("Finds offset for simple layout name", () => {
7+
const yaml = "layout:\n - name: template.cnfg\n source: wells";
8+
const offset = findLayoutNameOffset(yaml, "template.cnfg");
9+
expect(offset).to.equal(yaml.indexOf("template.cnfg"));
10+
});
11+
12+
it("Finds offset without leading dash", () => {
13+
const yaml = "layout:\n name: myfile.cnfg\n";
14+
const offset = findLayoutNameOffset(yaml, "myfile.cnfg");
15+
expect(offset).to.equal(yaml.indexOf("myfile.cnfg"));
16+
});
17+
18+
it("Returns -1 when name is not found", () => {
19+
const yaml = "layout:\n - name: other.cnfg\n";
20+
const offset = findLayoutNameOffset(yaml, "missing.cnfg");
21+
expect(offset).to.equal(-1);
22+
});
23+
24+
it("Handles special regex characters in filename", () => {
25+
const yaml = "layout:\n - name: file(1).cnfg\n";
26+
const offset = findLayoutNameOffset(yaml, "file(1).cnfg");
27+
expect(offset).to.equal(yaml.indexOf("file(1).cnfg"));
28+
});
29+
30+
it("Finds correct offset with multiple layout entries", () => {
31+
const yaml = "layout:\n - name: first.cnfg\n - name: second.cnfg\n";
32+
const offset = findLayoutNameOffset(yaml, "second.cnfg");
33+
expect(offset).to.equal(yaml.indexOf("second.cnfg"));
34+
});
35+
});
36+
37+
describe("Test findCaseDiscrepancies", () => {
38+
const yamlText =
39+
"layout:\n - name: Template.cnfg\n - name: correct.cnfg\n - name: Other.cnfg\n";
40+
41+
it("Detects case mismatch between layout name and directory entry", () => {
42+
const layoutNames = ["Template.cnfg"];
43+
const dirEntries = ["template.cnfg"];
44+
const result = findCaseDiscrepancies(layoutNames, dirEntries, yamlText);
45+
expect(result).to.have.lengthOf(1);
46+
expect(result[0].fileName).to.equal("Template.cnfg");
47+
expect(result[0].actualName).to.equal("template.cnfg");
48+
expect(result[0].offset).to.equal(yamlText.indexOf("Template.cnfg"));
49+
});
50+
51+
it("Returns empty when casing matches exactly", () => {
52+
const layoutNames = ["correct.cnfg"];
53+
const dirEntries = ["correct.cnfg"];
54+
const result = findCaseDiscrepancies(layoutNames, dirEntries, yamlText);
55+
expect(result).to.have.lengthOf(0);
56+
});
57+
58+
it("Returns empty when file does not exist in directory", () => {
59+
const layoutNames = ["nonexistent.cnfg"];
60+
const dirEntries = ["template.cnfg", "correct.cnfg"];
61+
const result = findCaseDiscrepancies(layoutNames, dirEntries, yamlText);
62+
expect(result).to.have.lengthOf(0);
63+
});
64+
65+
it("Detects multiple case mismatches", () => {
66+
const layoutNames = ["Template.cnfg", "correct.cnfg", "Other.cnfg"];
67+
const dirEntries = ["template.cnfg", "correct.cnfg", "other.cnfg"];
68+
const result = findCaseDiscrepancies(layoutNames, dirEntries, yamlText);
69+
expect(result).to.have.lengthOf(2);
70+
expect(result[0].fileName).to.equal("Template.cnfg");
71+
expect(result[0].actualName).to.equal("template.cnfg");
72+
expect(result[1].fileName).to.equal("Other.cnfg");
73+
expect(result[1].actualName).to.equal("other.cnfg");
74+
});
75+
76+
it("Returns empty for empty layout names", () => {
77+
const result = findCaseDiscrepancies([], ["file.cnfg"], yamlText);
78+
expect(result).to.have.lengthOf(0);
79+
});
80+
81+
it("Returns empty for empty directory entries", () => {
82+
const result = findCaseDiscrepancies(["Template.cnfg"], [], yamlText);
83+
expect(result).to.have.lengthOf(0);
84+
});
85+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Equinor ASA
3+
* Licensed under the MIT License. See LICENSE in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
export interface CaseDiscrepancy {
7+
fileName: string;
8+
actualName: string;
9+
offset: number;
10+
}
11+
12+
export function findCaseDiscrepancies(
13+
layoutNames: string[],
14+
dirEntries: string[],
15+
yamlText: string,
16+
): CaseDiscrepancy[] {
17+
const discrepancies: CaseDiscrepancy[] = [];
18+
for (const fileName of layoutNames) {
19+
const actualEntry = dirEntries.find(
20+
(entry) => entry.toLowerCase() === fileName.toLowerCase(),
21+
);
22+
if (actualEntry && actualEntry !== fileName) {
23+
const offset = findLayoutNameOffset(yamlText, fileName);
24+
if (offset >= 0) {
25+
discrepancies.push({
26+
fileName,
27+
actualName: actualEntry,
28+
offset,
29+
});
30+
}
31+
}
32+
}
33+
return discrepancies;
34+
}
35+
36+
export function findLayoutNameOffset(text: string, fileName: string): number {
37+
const regex = new RegExp(
38+
`(?:^|\\n)\\s*-?\\s*name:\\s*${escapeRegExp(fileName)}`,
39+
"m",
40+
);
41+
const match = regex.exec(text);
42+
if (match) {
43+
return match.index + match[0].indexOf(fileName);
44+
}
45+
return -1;
46+
}
47+
48+
function escapeRegExp(str: string): string {
49+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50+
}

packages/septic/src/diagnostics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export enum SepticDiagnosticCode {
9898
invalidReference = "W502",
9999
unusedEvr = "W503",
100100
duplicate = "W504",
101+
caseDiscrepancyPath = "W701",
101102
invalidComment = "W601",
102103
}
103104

0 commit comments

Comments
 (0)