diff --git a/src/ExportEditor.ts b/src/ExportEditor.ts index 1989c79..d1c2f70 100644 --- a/src/ExportEditor.ts +++ b/src/ExportEditor.ts @@ -19,9 +19,12 @@ interface FileDescriptor { interface Parameters { time_limit: string; memory_limit: string; + max_base: string; ncpus: string; mpi_nbcpu: string; mpi_nbnoeud: string; + testlist: string; + expected_diag: string; } interface FormData { @@ -299,9 +302,12 @@ export class ExportEditor implements vscode.Disposable { parameters: { time_limit: '', memory_limit: '', + max_base: '', ncpus: '', mpi_nbcpu: '', mpi_nbnoeud: '', + testlist: '', + expected_diag: '', }, inputFiles: [], outputFiles: [], @@ -318,23 +324,24 @@ export class ExportEditor implements vscode.Disposable { const tokens = cleanLine.split(/\s+/); - if (tokens[0] === 'P' && tokens.length === 3) { - const [, key, value] = tokens; + if (tokens[0] === 'P' && tokens.length >= 3) { + const key = tokens[1]; + const value = tokens.slice(2).join(' '); if (key in formData.parameters) { formData.parameters[key as keyof Parameters] = value; } } - if (tokens[0] === 'F' && tokens.length === 5) { + if ((tokens[0] === 'F' || tokens[0] === 'R') && tokens.length === 5) { const [, type, name, ioFlag, unit] = tokens; const fileObj: FileDescriptor = { type: type, name: name, unit: unit, }; - if (ioFlag === 'D') { + if (ioFlag === 'D' || ioFlag === 'DC') { formData.inputFiles.push(fileObj); - } else if (ioFlag === 'R') { + } else if (ioFlag === 'R' || ioFlag === 'RC') { formData.outputFiles.push(fileObj); } } diff --git a/src/ExportFormatter.ts b/src/ExportFormatter.ts index e473235..c3a640e 100644 --- a/src/ExportFormatter.ts +++ b/src/ExportFormatter.ts @@ -4,6 +4,7 @@ interface Entry { comments: string[]; line: string; type: string; + head?: 'F' | 'R'; direction?: 'D' | 'R'; } @@ -29,6 +30,9 @@ function typeRank(type: string): number { } function byType(a: Entry, b: Entry): number { + if (a.head !== b.head) { + return a.head === 'F' ? -1 : 1; + } const pa = typeRank(a.type); const pb = typeRank(b.type); if (pa !== pb) { @@ -41,8 +45,11 @@ const SECTION_HEADERS = { parameters: '# Simulation parameters', inputs: '# Input files', outputs: '# Output files', + unknown: '# Unknown lines', } as const; +const VALID_IO_FLAGS = new Set(['D', 'DC', 'R', 'RC']); + const STATIC_HEADER_LINES = [ '# This file was generated using VS Code Aster - https://github.com/simvia-tech/vs-code-aster', '# VS Code Aster is an open-source project maintained by Simvia - https://simvia.tech', @@ -72,6 +79,7 @@ export function formatExportContent(text: string, filename?: string): string { const lines = text.split(/\r?\n/); const pEntries: Entry[] = []; const fEntries: Entry[] = []; + const unknownEntries: Entry[] = []; let pendingComments: string[] = []; for (const line of lines) { @@ -87,18 +95,24 @@ export function formatExportContent(text: string, filename?: string): string { } const tokens = trimmed.split(/\s+/); const head = tokens[0]; - if (head === 'P') { + const isValidFR = + (head === 'F' || head === 'R') && tokens.length === 5 && VALID_IO_FLAGS.has(tokens[3] ?? ''); + if (head === 'P' && tokens.length >= 2) { pEntries.push({ comments: pendingComments, line: trimmed, type: '' }); pendingComments = []; - } else if (head === 'F') { - const direction: 'D' | 'R' = tokens[3] === 'D' ? 'D' : 'R'; + } else if (isValidFR) { + const direction: 'D' | 'R' = tokens[3] === 'D' || tokens[3] === 'DC' ? 'D' : 'R'; fEntries.push({ comments: pendingComments, line: trimmed, type: tokens[1] ?? '', + head: head as 'F' | 'R', direction, }); pendingComments = []; + } else { + unknownEntries.push({ comments: pendingComments, line: trimmed, type: '' }); + pendingComments = []; } } @@ -123,6 +137,9 @@ export function formatExportContent(text: string, filename?: string): string { if (rEntries.length > 0) { sections.push(`${SECTION_HEADERS.outputs}\n${renderSection(rEntries)}`); } + if (unknownEntries.length > 0) { + sections.push(`${SECTION_HEADERS.unknown}\n${renderSection(unknownEntries)}`); + } if (pendingComments.length > 0) { sections.push(pendingComments.join('\n')); } diff --git a/syntaxes/export.tmLanguage.json b/syntaxes/export.tmLanguage.json index c5ea6a4..64e74b7 100644 --- a/syntaxes/export.tmLanguage.json +++ b/syntaxes/export.tmLanguage.json @@ -5,7 +5,8 @@ "patterns": [ { "include": "#comment" }, { "include": "#parameter-line" }, - { "include": "#file-line" } + { "include": "#file-line" }, + { "include": "#unknown-line" } ], "repository": { "comment": { @@ -30,7 +31,7 @@ ] }, "file-line": { - "begin": "^\\s*(F)\\s+(\\S+)\\s+(\\S+)\\s+(?:(D)|(RC?))\\s+(\\S+)", + "begin": "^\\s*([FR])\\s+(\\S+)\\s+(\\S+)\\s+(?:(DC?)|(RC?))\\s+(\\S+)", "end": "$", "beginCaptures": { "1": { "name": "keyword.control.file.export" }, @@ -44,6 +45,10 @@ { "include": "#comment" }, { "include": "#extra-token" } ] + }, + "unknown-line": { + "match": "^\\s*[^#\\s].*$", + "name": "invalid.illegal.unknown-line.export" } } } diff --git a/webviews/export/src/components/App.svelte b/webviews/export/src/components/App.svelte index e1a3199..e8e0d33 100644 --- a/webviews/export/src/components/App.svelte +++ b/webviews/export/src/components/App.svelte @@ -4,6 +4,7 @@ import FieldRow from './FieldRow.svelte'; import FileSection from './FileSection.svelte'; import SubmitBar from './SubmitBar.svelte'; + import Dropdown from './ui/Dropdown.svelte'; import { DEFAULT_UNITS, getNextAvailableUnit, @@ -26,19 +27,81 @@ console.error('acquireVsCodeApi failed', e); } + const INTEGER_PARAMS = new Set([ + 'time_limit', + 'memory_limit', + 'max_base', + 'ncpus', + 'mpi_nbcpu', + 'mpi_nbnoeud', + ]); + const OPTIONAL_PARAMS = new Set(['max_base', 'testlist', 'expected_diag']); + let formData = $state({ name: 'simvia', parameters: { time_limit: '300', memory_limit: '1024', + max_base: '', ncpus: '1', mpi_nbcpu: '4', mpi_nbnoeud: '1', + testlist: '', + expected_diag: '', }, inputFiles: [], outputFiles: [], }); + const TESTLIST_CONCURRENCY = ['sequential', 'parallel'] as const; + const TESTLIST_CATEGORY = ['verification', 'validation'] as const; + type Concurrency = (typeof TESTLIST_CONCURRENCY)[number] | ''; + type Category = (typeof TESTLIST_CATEGORY)[number] | ''; + + function parseTestlist(raw: string): { + concurrency: Concurrency; + category: Category; + projects: string; + } { + let concurrency: Concurrency = ''; + let category: Category = ''; + const projects: string[] = []; + for (const token of raw.trim().split(/\s+/).filter(Boolean)) { + if ((TESTLIST_CONCURRENCY as readonly string[]).includes(token)) { + concurrency = token as Concurrency; + } else if ((TESTLIST_CATEGORY as readonly string[]).includes(token)) { + category = token as Category; + } else { + projects.push(token); + } + } + return { concurrency, category, projects: projects.join(' ') }; + } + + function serializeTestlist( + concurrency: Concurrency, + category: Category, + projects: string + ): string { + const parts: string[] = []; + if (concurrency) parts.push(concurrency); + if (category) parts.push(category); + const trimmedProjects = projects.trim(); + if (category === 'validation' && trimmedProjects) { + parts.push(trimmedProjects); + } + return parts.join(' '); + } + + let testlist = $state(parseTestlist('')); + + $effect(() => { + const next = serializeTestlist(testlist.concurrency, testlist.category, testlist.projects); + if (next !== formData.parameters.testlist) { + formData.parameters.testlist = next; + } + }); + let suggestionsFor = $state>({}); let lastQueriedId: string | null = null; @@ -58,28 +121,42 @@ const errors: Record = {}; if (formData.name.trim() === '') { - errors['envName'] = 'File name is required.'; + errors['envName'] = 'The export file name is required.'; } for (const [key, value] of Object.entries(formData.parameters)) { + if (!INTEGER_PARAMS.has(key)) { + continue; + } + if (OPTIONAL_PARAMS.has(key) && value.trim() === '') { + continue; + } if (!isInteger(value)) { - errors[key] = `"${key}" must be an integer.`; + errors[key] = `"${key}" must be a whole number.`; } } const checkFiles = (files: FileDescriptor[], label: 'Input' | 'Output') => { - files.forEach((f) => { + files.forEach((f, idx) => { if (isEmptyFile(f)) { return; } - if (!f.name.trim()) { - errors[`name-${f.id}`] = `${label} file: missing file name.`; - } + const prefix = `${label} file #${idx + 1}`; + const trimmedName = f.name.trim(); + const dotIdx = trimmedName.lastIndexOf('.'); + const base = dotIdx >= 0 ? trimmedName.slice(0, dotIdx) : trimmedName; + const ext = dotIdx >= 0 ? trimmedName.slice(dotIdx + 1) : ''; if (!f.type.trim()) { - errors[`type-${f.id}`] = `${label} file: missing type.`; + errors[`type-${f.id}`] = `${prefix}: file type is required.`; + } + if (!base) { + errors[`name-${f.id}`] = `${prefix}: file name is required.`; + } + if (!ext) { + errors[`ext-${f.id}`] = `${prefix}: file extension is required.`; } if (!isInteger(f.unit)) { - errors[`unit-${f.id}`] = 'Unit must be an integer.'; + errors[`unit-${f.id}`] = `${prefix}: unit must be a whole number.`; } }); }; @@ -98,8 +175,22 @@ return out; }); + let allErrors = $derived<{ targetId?: string; message: string }[]>([ + ...Object.entries(errorFor).map(([targetId, message]) => ({ targetId, message })), + ...formErrors.map((message) => ({ message })), + ]); + + function focusTarget(id: string) { + const el = document.getElementById(id); + if (!el) { + return; + } + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + (el as HTMLElement).focus({ preventScroll: true }); + } + let warnings = $derived.by(() => { - const out: string[] = []; + const out: { targetId?: string; message: string }[] = []; if ( mode === 'edit' && @@ -107,9 +198,10 @@ formData.name.trim() && formData.name.trim() !== originalName ) { - out.push( - `Saving will rename ${originalName}.export to ${formData.name.trim()}.export. The original file will be deleted.` - ); + out.push({ + targetId: 'envName', + message: `Saving will rename ${originalName}.export to ${formData.name.trim()}.export. The original file will be deleted.`, + }); } const meshTypes = new Set(['mmed', 'mail', 'msh']); @@ -117,15 +209,21 @@ (f) => !isEmptyFile(f) && meshTypes.has(f.type) ); if (!hasMesh) { - out.push('No mesh file is set (mmed, mail, or msh).'); + out.push({ + targetId: 'add-input', + message: 'No mesh file is set (mmed, mail, or msh).', + }); } const hasRmed = formData.outputFiles.some((f) => !isEmptyFile(f) && f.type === 'rmed'); if (!hasRmed) { - out.push('No rmed output file is set.'); + out.push({ + targetId: 'add-output', + message: 'No rmed output file is set.', + }); } - const byUnit = new Map(); + const byUnit = new Map(); for (const f of [...formData.inputFiles, ...formData.outputFiles]) { if (isEmptyFile(f)) { continue; @@ -138,13 +236,19 @@ continue; } const label = f.name.trim() || `(unnamed ${f.type || 'file'})`; - const existing = byUnit.get(unitStr) ?? []; - existing.push(label); - byUnit.set(unitStr, existing); + const existing = byUnit.get(unitStr); + if (existing) { + existing.names.push(label); + } else { + byUnit.set(unitStr, { names: [label], firstId: f.id }); + } } - for (const [unit, names] of byUnit) { + for (const [unit, { names, firstId }] of byUnit) { if (names.length > 1) { - out.push(`Multiple files share unit ${unit}: ${names.join(', ')}`); + out.push({ + targetId: `unit-${firstId}`, + message: `Multiple files share unit ${unit}: ${names.join(', ')}`, + }); } } return out; @@ -199,19 +303,27 @@ const lines: string[] = []; lines.push(`${formData.name.trim()}.export`); for (const [key, value] of Object.entries(formData.parameters)) { - lines.push(`P ${key} ${value.trim()}`); + const trimmed = value.trim(); + if (OPTIONAL_PARAMS.has(key) && trimmed === '') { + continue; + } + lines.push(`P ${key} ${trimmed}`); } for (const f of formData.inputFiles) { if (isEmptyFile(f)) { continue; } - lines.push(`F ${f.type} ${f.name.trim()} D ${f.unit.trim()}`); + const head = f.type === 'base' ? 'R' : 'F'; + const status = f.type === 'base' ? 'DC' : 'D'; + lines.push(`${head} ${f.type} ${f.name.trim()} ${status} ${f.unit.trim()}`); } for (const f of formData.outputFiles) { if (isEmptyFile(f)) { continue; } - lines.push(`F ${f.type} ${f.name.trim()} R ${f.unit.trim()}`); + const head = f.type === 'base' ? 'R' : 'F'; + const status = f.type === 'base' ? 'RC' : 'R'; + lines.push(`${head} ${f.type} ${f.name.trim()} ${status} ${f.unit.trim()}`); } vscode?.postMessage({ command: 'result', value: lines.join('\n') }); } @@ -235,6 +347,7 @@ const saved = vscode?.getState() as { formData?: FormData } | undefined; if (saved?.formData) { Object.assign(formData, saved.formData); + testlist = parseTestlist(formData.parameters.testlist ?? ''); loadedFromState = true; } else { formData.inputFiles.push({ @@ -336,6 +449,7 @@ params[key] = v; } } + testlist = parseTestlist(formData.parameters.testlist); } if ( (data.inputFiles && data.inputFiles.length > 0) || @@ -406,15 +520,96 @@ bind:value={formData.parameters.mpi_nbcpu} error={errorFor['mpi_nbcpu']} /> -
- + + +
+ +
+
+ + Test list + +
+ ({ value: v, label: v })), + ]} + value={testlist.concurrency || null} + onSelect={(v) => (testlist.concurrency = v as Concurrency)} + > + + + ({ value: v, label: v })), + ]} + value={testlist.category || null} + onSelect={(v) => (testlist.category = v as Category)} + > + + + +
+ +
- {#if formErrors.length > 0} + {#if allErrors.length > 0} +
    + {#each allErrors as e} +
  • +
  • {/each}
@@ -482,31 +688,44 @@ {#if warnings.length > 0} - {#if nameError || unitError || typeError} -

- {nameError || typeError || unitError} -

+ {#if rowErrors.length > 0} +
    + {#each rowErrors as message} +
  • {message}
  • + {/each} +
{/if} diff --git a/webviews/export/src/components/FileSection.svelte b/webviews/export/src/components/FileSection.svelte index 2ac1b77..9b2cf46 100644 --- a/webviews/export/src/components/FileSection.svelte +++ b/webviews/export/src/components/FileSection.svelte @@ -80,6 +80,7 @@