From df31b1121ebce461d385630ef8fe6249890cd7c4 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Wed, 30 Sep 2026 15:48:19 -0400 Subject: [PATCH 1/2] Read a project store whole, and project it into a main page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `.prj` is a marker file; everything about the project lives in the sibling `resources/project/` store, read entirely by convention. Five things that store says were being read wrongly or not at all, each of which made the viewer state something false about a real project: - A File entity's `location` is relative to its PARENT entity, not to the project root. Read verbatim, every file inside a folder reported a bare basename, so `utils/helper.m` and a root-level `helper.m` produced the same row. 357 of monophonic_syntethizer's 372 members are nested. - A path folder, a working folder and a project->project reference are all spelled `type="Reference"`; only the collection they sit in separates them. A scan across every collection therefore INVENTED references — that project has none and was reported as referencing three. The collections whose meaning we model are now skipped by name, rather than allow-listing the ones that hold references, because that collection's spelling varies by release and a missed spelling would drop real references. - The project root is on the MATLAB path, recorded as `Ref=""`, and is normally the first folder MATLAB adds. Testing the Ref for truthiness dropped it from almost every project. - Two collections of one type can coexist: a real project carries both `location="Root" type="Files"` (its members) and `location="ALM" type="Files"` (artifact tracking). Matching on type alone assigned the member list twice and let store order pick the winner — and the ALM collection holds no member files at all, only a DIR_SIGNIFIER, so losing that race meant zero members. - The `distributed` layout was not read at all. It carries no pointer documents: an entity's location and type are its filename. Both layouts now normalize to one entity shape, so every collection reader is layout-blind. A layout we cannot walk is reported instead of guessed at, since the readers would otherwise return a project that looks complete and empty. Then the parts of a project that were never modelled, which are most of what a project IS: entry points (shortcuts and the startup/shutdown files, including the run order the store encodes as a `*Prev` linked list and nothing else recovers), shortcut groups, the designated cache/codegen/startup locations, whether a label is MATLAB's or the project's own, and the declared metadata format. `buildProjectPage` projects all of it into the view model behind a project's main page. A project is not a table: MATLAB opens no document tab for one, and the file tree a table would show is already in the Explorer. What is nowhere else is everything around the file list, so that is what a page shows. The grouping, the run-order split, the label usage counts and the coverage figure are facts about the store rather than presentation, so they are derived once here and testable without a webview. Verified against two real stores: monophonic_syntethizer (fixedPathV2, 1166 metadata documents) and soc_swhw (distributed), both now reading with no warnings. --- .../container/ProjectSectionNode.d.ts.map | 2 +- .../node/container/ProjectSectionNode.js | 12 +- .../node/container/ProjectSectionNode.js.map | 2 +- dist/datamodel/parser/ProjectPage.d.ts | 75 ++ dist/datamodel/parser/ProjectPage.d.ts.map | 1 + dist/datamodel/parser/ProjectPage.js | 167 ++++ dist/datamodel/parser/ProjectPage.js.map | 1 + dist/datamodel/parser/ProjectParser.d.ts | 70 ++ dist/datamodel/parser/ProjectParser.d.ts.map | 2 +- dist/datamodel/parser/ProjectParser.js | 615 +++++++++++--- dist/datamodel/parser/ProjectParser.js.map | 2 +- dist/index.d.ts | 4 +- dist/index.d.ts.map | 2 +- dist/index.js | 5 + dist/index.js.map | 2 +- .../node/container/ProjectSectionNode.ts | 14 +- src/datamodel/parser/ProjectPage.ts | 253 ++++++ src/datamodel/parser/ProjectParser.ts | 769 +++++++++++++++--- src/index.ts | 24 +- test/projectNode.test.ts | 13 + test/projectPage.test.ts | 272 +++++++ test/projectParser.test.ts | 423 +++++++++- 22 files changed, 2448 insertions(+), 282 deletions(-) create mode 100644 dist/datamodel/parser/ProjectPage.d.ts create mode 100644 dist/datamodel/parser/ProjectPage.d.ts.map create mode 100644 dist/datamodel/parser/ProjectPage.js create mode 100644 dist/datamodel/parser/ProjectPage.js.map create mode 100644 src/datamodel/parser/ProjectPage.ts create mode 100644 test/projectPage.test.ts diff --git a/dist/datamodel/node/container/ProjectSectionNode.d.ts.map b/dist/datamodel/node/container/ProjectSectionNode.d.ts.map index e0bc9af..1cc8394 100644 --- a/dist/datamodel/node/container/ProjectSectionNode.d.ts.map +++ b/dist/datamodel/node/container/ProjectSectionNode.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"ProjectSectionNode.d.ts","sourceRoot":"","sources":["../../../../src/datamodel/node/container/ProjectSectionNode.ts"],"names":[],"mappings":"AAEA,OAAO,aAAa,MAAM,qBAAqB,CAAC;AAChD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,OAAO,KAAK,QAAQ,MAAM,gBAAgB,CAAC;AAC3C,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAEjG,MAAM,CAAC,OAAO,OAAO,kBAAmB,SAAQ,aAAa;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;gBAEH,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;IAMhF,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,IAAI,iBAAiB,IAAI,iBAAiB,CAEzC;IAED,YAAY,CAAC,IAAI,EAAE,WAAW,GAAG,QAAQ;IAWzC,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ;IAUtC,aAAa,CAAC,KAAK,EAAE,YAAY,GAAG,QAAQ;IAS5C,iBAAiB,CAAC,GAAG,EAAE,gBAAgB,GAAG,QAAQ;CAQrD"} \ No newline at end of file +{"version":3,"file":"ProjectSectionNode.d.ts","sourceRoot":"","sources":["../../../../src/datamodel/node/container/ProjectSectionNode.ts"],"names":[],"mappings":"AAEA,OAAO,aAAa,MAAM,qBAAqB,CAAC;AAChD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,OAAO,KAAK,QAAQ,MAAM,gBAAgB,CAAC;AAC3C,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAKjG,MAAM,CAAC,OAAO,OAAO,kBAAmB,SAAQ,aAAa;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;gBAEH,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;IAMhF,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,IAAI,iBAAiB,IAAI,iBAAiB,CAEzC;IAED,YAAY,CAAC,IAAI,EAAE,WAAW,GAAG,QAAQ;IAWzC,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ;IAmBtC,aAAa,CAAC,KAAK,EAAE,YAAY,GAAG,QAAQ;IAS5C,iBAAiB,CAAC,GAAG,EAAE,gBAAgB,GAAG,QAAQ;CAQrD"} \ No newline at end of file diff --git a/dist/datamodel/node/container/ProjectSectionNode.js b/dist/datamodel/node/container/ProjectSectionNode.js index 03aae4e..9d72b30 100644 --- a/dist/datamodel/node/container/ProjectSectionNode.js +++ b/dist/datamodel/node/container/ProjectSectionNode.js @@ -1,6 +1,8 @@ // Copyright 2026 The MathWorks, Inc. import ContainerNode from '../ContainerNode.js'; import ProjectItemNode from '../data/ProjectItemNode.js'; +/** What to call the project root in a list of path folders. See addPathEntry. */ +const ROOT_FOLDER_NAME = '(project root)'; export default class ProjectSectionNode extends ContainerNode { constructor(name, parent, label, iconId) { super(name, parent); @@ -27,7 +29,15 @@ export default class ProjectSectionNode extends ContainerNode { return node; } addPathEntry(folder) { - const name = folder.split(/[/\\]/).filter((p) => p.length > 0).pop() || folder; + // The project ROOT is on the path, and the store spells it as the EMPTY + // string — so unlike every other entry it has no segment to be named by, and + // the usual "show the path itself" fallback leaves a blank row. It is + // normally the first folder MATLAB adds, so this is the common first row and + // not an edge case. Only '' is the root: a degenerate path like '/' is still + // shown verbatim, since that one at least says something. + const name = folder === '' + ? ROOT_FOLDER_NAME + : folder.split(/[/\\]/).filter((p) => p.length > 0).pop() || folder; const node = new ProjectItemNode(name, this, { itemType: 'Path Folder', location: folder, diff --git a/dist/datamodel/node/container/ProjectSectionNode.js.map b/dist/datamodel/node/container/ProjectSectionNode.js.map index 7c4e2e2..de0d440 100644 --- a/dist/datamodel/node/container/ProjectSectionNode.js.map +++ b/dist/datamodel/node/container/ProjectSectionNode.js.map @@ -1 +1 @@ -{"version":3,"file":"ProjectSectionNode.js","sourceRoot":"","sources":["../../../../src/datamodel/node/container/ProjectSectionNode.ts"],"names":[],"mappings":"AAAA,qCAAqC;AAErC,OAAO,aAAa,MAAM,qBAAqB,CAAC;AAEhD,OAAO,eAAe,MAAM,4BAA4B,CAAC;AAIzD,MAAM,CAAC,OAAO,OAAO,kBAAmB,SAAQ,aAAa;IAIzD,YAAY,IAAY,EAAE,MAAuB,EAAE,KAAa,EAAE,MAAc;QAC5E,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QACpB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACzB,CAAC;IAED,IAAI,IAAI;QACJ,OAAO,IAAI,CAAC,MAAM,CAAC;IACvB,CAAC;IAED,IAAI,WAAW;QACX,OAAO,IAAI,CAAC,KAAK,CAAC;IACtB,CAAC;IAED,IAAI,iBAAiB;QACjB,OAAO,EAAE,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,CAAC,EAAE,CAAC;IAC/D,CAAC;IAED,YAAY,CAAC,IAAiB;QAC1B,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,IAAI,IAAI,CAAC,IAAI,CAAC;QACzD,MAAM,IAAI,GAAG,IAAI,eAAe,CAAC,IAAI,EAAE,IAAI,EAAE;YACzC,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM;YAC3C,QAAQ,EAAE,IAAI,CAAC,IAAI;YACnB,MAAM,EAAE,IAAI,CAAC,MAAM;SACtB,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACpB,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,YAAY,CAAC,MAAc;QACvB,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,IAAI,MAAM,CAAC;QAC/E,MAAM,IAAI,GAAG,IAAI,eAAe,CAAC,IAAI,EAAE,IAAI,EAAE;YACzC,QAAQ,EAAE,aAAa;YACvB,QAAQ,EAAE,MAAM;SACnB,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACpB,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,aAAa,CAAC,KAAmB;QAC7B,MAAM,IAAI,GAAG,IAAI,eAAe,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,EAAE;YAC/C,QAAQ,EAAE,OAAO;YACjB,QAAQ,EAAE,KAAK,CAAC,QAAQ;SAC3B,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACpB,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,iBAAiB,CAAC,GAAqB;QACnC,MAAM,IAAI,GAAG,IAAI,eAAe,CAAC,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,EAAE,EAAE,IAAI,EAAE;YACvD,QAAQ,EAAE,WAAW;YACrB,QAAQ,EAAE,GAAG,CAAC,EAAE;SACnB,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACpB,OAAO,IAAI,CAAC;IAChB,CAAC;CACJ"} \ No newline at end of file +{"version":3,"file":"ProjectSectionNode.js","sourceRoot":"","sources":["../../../../src/datamodel/node/container/ProjectSectionNode.ts"],"names":[],"mappings":"AAAA,qCAAqC;AAErC,OAAO,aAAa,MAAM,qBAAqB,CAAC;AAEhD,OAAO,eAAe,MAAM,4BAA4B,CAAC;AAIzD,iFAAiF;AACjF,MAAM,gBAAgB,GAAG,gBAAgB,CAAC;AAE1C,MAAM,CAAC,OAAO,OAAO,kBAAmB,SAAQ,aAAa;IAIzD,YAAY,IAAY,EAAE,MAAuB,EAAE,KAAa,EAAE,MAAc;QAC5E,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QACpB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACzB,CAAC;IAED,IAAI,IAAI;QACJ,OAAO,IAAI,CAAC,MAAM,CAAC;IACvB,CAAC;IAED,IAAI,WAAW;QACX,OAAO,IAAI,CAAC,KAAK,CAAC;IACtB,CAAC;IAED,IAAI,iBAAiB;QACjB,OAAO,EAAE,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,CAAC,EAAE,CAAC;IAC/D,CAAC;IAED,YAAY,CAAC,IAAiB;QAC1B,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,IAAI,IAAI,CAAC,IAAI,CAAC;QACzD,MAAM,IAAI,GAAG,IAAI,eAAe,CAAC,IAAI,EAAE,IAAI,EAAE;YACzC,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM;YAC3C,QAAQ,EAAE,IAAI,CAAC,IAAI;YACnB,MAAM,EAAE,IAAI,CAAC,MAAM;SACtB,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACpB,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,YAAY,CAAC,MAAc;QACvB,wEAAwE;QACxE,6EAA6E;QAC7E,sEAAsE;QACtE,6EAA6E;QAC7E,6EAA6E;QAC7E,0DAA0D;QAC1D,MAAM,IAAI,GACN,MAAM,KAAK,EAAE;YACT,CAAC,CAAC,gBAAgB;YAClB,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,IAAI,MAAM,CAAC;QAC5E,MAAM,IAAI,GAAG,IAAI,eAAe,CAAC,IAAI,EAAE,IAAI,EAAE;YACzC,QAAQ,EAAE,aAAa;YACvB,QAAQ,EAAE,MAAM;SACnB,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACpB,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,aAAa,CAAC,KAAmB;QAC7B,MAAM,IAAI,GAAG,IAAI,eAAe,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,EAAE;YAC/C,QAAQ,EAAE,OAAO;YACjB,QAAQ,EAAE,KAAK,CAAC,QAAQ;SAC3B,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACpB,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,iBAAiB,CAAC,GAAqB;QACnC,MAAM,IAAI,GAAG,IAAI,eAAe,CAAC,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,EAAE,EAAE,IAAI,EAAE;YACvD,QAAQ,EAAE,WAAW;YACrB,QAAQ,EAAE,GAAG,CAAC,EAAE;SACnB,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACpB,OAAO,IAAI,CAAC;IAChB,CAAC;CACJ"} \ No newline at end of file diff --git a/dist/datamodel/parser/ProjectPage.d.ts b/dist/datamodel/parser/ProjectPage.d.ts new file mode 100644 index 0000000..e7c0d82 --- /dev/null +++ b/dist/datamodel/parser/ProjectPage.d.ts @@ -0,0 +1,75 @@ +import type { ParsedProject } from './ProjectParser.js'; +import type { ParseWarning } from './ParseWarning.js'; +/** One file MATLAB runs for a hook. Position in the list IS the run order. */ +export interface ProjectPageRun { + name: string; + /** Project-root-relative path. */ + file: string; +} +/** One entry in the Shortcuts gallery. */ +export interface ProjectPageShortcut { + name: string; + /** Project-root-relative path. May name a FOLDER. */ + file: string; + /** The group's display name, '' when ungrouped. */ + group: string; +} +/** One label, with how much of the project carries it. */ +export interface ProjectPageLabel { + id: string; + name: string; + /** Members carrying this label. 0 is meaningful — a defined but unused label. */ + count: number; + /** Defined by this project rather than shipped by MATLAB. */ + custom: boolean; +} +export interface ProjectPageCategory { + name: string; + labels: ProjectPageLabel[]; +} +/** A folder — or file — the project designates for a purpose. */ +export interface ProjectPageLocation { + /** The store's own key, e.g. 'SimulinkCacheFolder'. */ + key: string; + /** English for `key`, falling back to `key` itself when unrecognized. */ + label: string; + /** Project-root-relative path. */ + ref: string; +} +export interface ProjectPageReference { + name: string; + /** The reference id; a path when the store recorded one. */ + path: string; +} +export interface ProjectPage { + name: string; + /** The raw `MetadataType`, for a host that wants to report it exactly. */ + format: string; + /** English for `format` — what a MATLAB user sees in Project Settings. */ + formatLabel: string; + /** Every member, folders included: what the project "contains". */ + memberCount: number; + /** Members carrying at least one label — the numerator of label coverage. */ + labelledCount: number; + /** In run order. */ + startup: ProjectPageRun[]; + /** In run order. */ + shutdown: ProjectPageRun[]; + shortcuts: ProjectPageShortcut[]; + /** Project-root-relative; '' is the project root itself. */ + pathFolders: string[]; + locations: ProjectPageLocation[]; + categories: ProjectPageCategory[]; + references: ProjectPageReference[]; + warnings: ParseWarning[]; +} +/** + * Project a parsed project into its page. + * + * Pure and total: every field is derived from `parsed`, and a project that holds + * nothing yields a page of empty sections rather than a missing one — the sections + * are what tell a user "this project defines no shortcuts", which is a fact worth + * showing and not the same as a page that failed to render. + */ +export declare function buildProjectPage(parsed: ParsedProject): ProjectPage; +//# sourceMappingURL=ProjectPage.d.ts.map \ No newline at end of file diff --git a/dist/datamodel/parser/ProjectPage.d.ts.map b/dist/datamodel/parser/ProjectPage.d.ts.map new file mode 100644 index 0000000..ea77ea4 --- /dev/null +++ b/dist/datamodel/parser/ProjectPage.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"ProjectPage.d.ts","sourceRoot":"","sources":["../../../src/datamodel/parser/ProjectPage.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,aAAa,EAAqB,MAAM,oBAAoB,CAAC;AAC3E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEtD,8EAA8E;AAC9E,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;CACd;AAED,0CAA0C;AAC1C,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,0DAA0D;AAC1D,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,iFAAiF;IACjF,KAAK,EAAE,MAAM,CAAC;IACd,6DAA6D;IAC7D,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,gBAAgB,EAAE,CAAC;CAC5B;AAED,iEAAiE;AACjE,MAAM,WAAW,mBAAmB;IAClC,uDAAuD;IACvD,GAAG,EAAE,MAAM,CAAC;IACZ,yEAAyE;IACzE,KAAK,EAAE,MAAM,CAAC;IACd,kCAAkC;IAClC,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,4DAA4D;IAC5D,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,WAAW,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,WAAW,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,aAAa,EAAE,MAAM,CAAC;IACtB,oBAAoB;IACpB,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,oBAAoB;IACpB,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,SAAS,EAAE,mBAAmB,EAAE,CAAC;IACjC,4DAA4D;IAC5D,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,SAAS,EAAE,mBAAmB,EAAE,CAAC;IACjC,UAAU,EAAE,mBAAmB,EAAE,CAAC;IAClC,UAAU,EAAE,oBAAoB,EAAE,CAAC;IACnC,QAAQ,EAAE,YAAY,EAAE,CAAC;CAC1B;AAmCD;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,aAAa,GAAG,WAAW,CA6BnE"} \ No newline at end of file diff --git a/dist/datamodel/parser/ProjectPage.js b/dist/datamodel/parser/ProjectPage.js new file mode 100644 index 0000000..2b78996 --- /dev/null +++ b/dist/datamodel/parser/ProjectPage.js @@ -0,0 +1,167 @@ +// Copyright 2026 The MathWorks, Inc. +// +// The view model behind a project's main page. +// +// A `.prj` is not a table. MATLAB itself opens NO document tab for one — its +// Project panel *is* the file panel — and the folder tree a table would show is +// already in VS Code's Explorer, so repeating it costs a tab and says nothing new. +// What is NOT anywhere else is everything around the file list: which files run on +// open and close and IN WHICH ORDER, the shortcuts and their groups, which folders +// are on the MATLAB path, where the cache and generated code go, which labels this +// project defines and how much of it they cover. That is what a page shows. +// +// WHY THIS LIVES BESIDE THE PARSER. It is a projection of `ParsedProject` and needs +// its types; `datamodel/display/` is pinned by `moduleBoundaries.test.ts` to have +// ZERO outbound edges, so it cannot be there. It is also not presentation: the +// grouping, the run-order split, the label usage counts and the coverage figure are +// all facts about the store, derived once here so that every host computes them the +// same way and so they are testable without a webview. +/** + * English for a working-folder key. + * + * Deliberately not exhaustive, and the fallback is the raw key: a newer release can + * designate a purpose this version has no word for, and showing `key` is honest + * where dropping the row or printing 'Unknown' is not. + */ +const LOCATION_LABEL = { + SimulinkCacheFolder: 'Simulation cache', + SimulinkCodeGenFolder: 'Code generation', + ProjectStartupFolder: 'Startup folder', + DependencyCacheFile: 'Dependency cache', +}; +/** + * English for a `MetadataType`. + * + * These are the words MATLAB's own Project Settings uses, not the store's spelling: + * `fixedPathV2` and `distributed` are two encodings of the same USER choice ("save + * as multiple XML files"), and a page that printed the internal name would invite a + * user to look for a setting that is not spelled that way anywhere in MATLAB. + */ +const FORMAT_LABEL = { + fixedPathV2: 'multiple XML files', + distributed: 'multiple XML files', + monolithic: 'single XML file', + toml: 'matlab.toml', +}; +/** The entry-point kinds MATLAB runs, as opposed to the ones a user clicks. */ +const STARTUP = 'StartUp'; +const SHUTDOWN = 'Shutdown'; +/** + * Project a parsed project into its page. + * + * Pure and total: every field is derived from `parsed`, and a project that holds + * nothing yields a page of empty sections rather than a missing one — the sections + * are what tell a user "this project defines no shortcuts", which is a fact worth + * showing and not the same as a page that failed to render. + */ +export function buildProjectPage(parsed) { + const runsOf = (kind) => parsed.entryPoints + .filter((e) => e.kind === kind) + // `parsed.entryPoints` is already in run order and `filter` preserves it, so + // there is deliberately no sort here — see ParsedProject.entryPoints. + .map((e) => ({ name: e.name, file: e.file })); + const groupNames = new Map(parsed.entryPointGroups.map((g) => [g.id, g.name])); + return { + name: parsed.name, + format: parsed.format, + formatLabel: FORMAT_LABEL[parsed.format] ?? parsed.format, + memberCount: parsed.files.length, + labelledCount: parsed.files.filter((f) => f.labels.length > 0).length, + startup: runsOf(STARTUP), + shutdown: runsOf(SHUTDOWN), + shortcuts: shortcutsOf(parsed.entryPoints, groupNames), + pathFolders: parsed.pathFolders, + locations: parsed.workingFolders.map((w) => ({ + key: w.key, + label: LOCATION_LABEL[w.key] ?? w.key, + ref: w.ref, + })), + categories: categoriesOf(parsed), + references: parsed.references.map((r) => ({ name: r.name ?? r.id, path: r.name ? r.id : '' })), + warnings: parsed.warnings, + }; +} +/** + * The Shortcuts gallery: every entry point that is not a hook. + * + * Selected by EXCLUDING the two run kinds rather than by asking for 'Basic'. A + * newer release can add a shortcut kind, and an unknown kind is far better shown in + * the gallery — where it is at worst grouped oddly — than silently absent from a + * page whose whole job is to list what this project put there. + */ +function shortcutsOf(entryPoints, groupNames) { + const out = []; + for (const e of entryPoints) { + if (e.kind === STARTUP || e.kind === SHUTDOWN) { + continue; + } + // `visible` is MATLAB's own answer to "does this belong in the gallery", so a + // hidden entry of any other kind stays out of it. + if (!e.visible) { + continue; + } + out.push({ + name: e.name, + file: e.file, + // A groupId naming no group falls back to ungrouped rather than to the raw + // UUID: a store can outlive the group it points at, and a gallery heading + // reading `a0ea675a-85ba-…` is worse than no heading. + group: groupNames.get(e.groupId) ?? '', + }); + } + // Grouped shortcuts first, so a host rendering group headings emits each heading + // once. Order WITHIN a group, and among the ungrouped, stays the store's. + return [...out.filter((s) => s.group), ...out.filter((s) => !s.group)]; +} +/** + * The label catalog with usage counts, grouped by category. + * + * Counted from the members' own assignments rather than from the catalog, because + * the two can disagree: a file may carry a label id the catalog does not define + * (written by a release that knew it, or by a hand-edited store). Such an id is + * counted under a category named for it, so the coverage figure stays consistent + * with `labelledCount` instead of quietly excluding files it includes. + */ +function categoriesOf(parsed) { + const counts = new Map(); + for (const file of parsed.files) { + // Per file, not per assignment: `parsed.files` already de-duplicates a file's + // labels, so each hit here is one member carrying one label. + for (const id of file.labels) { + counts.set(id, (counts.get(id) ?? 0) + 1); + } + } + const byCategory = new Map(); + const push = (category, label) => { + const list = byCategory.get(category); + if (list) { + list.push(label); + } + else { + byCategory.set(category, [label]); + } + }; + const defined = new Set(); + for (const l of parsed.labels) { + defined.add(l.id); + push(l.category, { + id: l.id, + name: l.name, + count: counts.get(l.id) ?? 0, + custom: !l.readOnly, + }); + } + for (const [id, count] of counts) { + if (!defined.has(id)) { + push('', { id, name: id, count, custom: true }); + } + } + // Used labels first, then by name: the seven built-ins ship with every project + // and most stay at zero, so catalog order buries the two or three that say + // something about THIS project under five that say nothing about any. + for (const labels of byCategory.values()) { + labels.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); + } + return [...byCategory.entries()].map(([name, labels]) => ({ name, labels })); +} +//# sourceMappingURL=ProjectPage.js.map \ No newline at end of file diff --git a/dist/datamodel/parser/ProjectPage.js.map b/dist/datamodel/parser/ProjectPage.js.map new file mode 100644 index 0000000..286af54 --- /dev/null +++ b/dist/datamodel/parser/ProjectPage.js.map @@ -0,0 +1 @@ +{"version":3,"file":"ProjectPage.js","sourceRoot":"","sources":["../../../src/datamodel/parser/ProjectPage.ts"],"names":[],"mappings":"AAAA,qCAAqC;AACrC,EAAE;AACF,+CAA+C;AAC/C,EAAE;AACF,6EAA6E;AAC7E,gFAAgF;AAChF,mFAAmF;AACnF,mFAAmF;AACnF,mFAAmF;AACnF,mFAAmF;AACnF,4EAA4E;AAC5E,EAAE;AACF,oFAAoF;AACpF,kFAAkF;AAClF,+EAA+E;AAC/E,oFAAoF;AACpF,oFAAoF;AACpF,uDAAuD;AA2EvD;;;;;;GAMG;AACH,MAAM,cAAc,GAA2B;IAC7C,mBAAmB,EAAE,kBAAkB;IACvC,qBAAqB,EAAE,iBAAiB;IACxC,oBAAoB,EAAE,gBAAgB;IACtC,mBAAmB,EAAE,kBAAkB;CACxC,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,YAAY,GAA2B;IAC3C,WAAW,EAAE,oBAAoB;IACjC,WAAW,EAAE,oBAAoB;IACjC,UAAU,EAAE,iBAAiB;IAC7B,IAAI,EAAE,aAAa;CACpB,CAAC;AAEF,+EAA+E;AAC/E,MAAM,OAAO,GAAG,SAAS,CAAC;AAC1B,MAAM,QAAQ,GAAG,UAAU,CAAC;AAE5B;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAqB;IACpD,MAAM,MAAM,GAAG,CAAC,IAAY,EAAoB,EAAE,CAChD,MAAM,CAAC,WAAW;SACf,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC;QAC/B,6EAA6E;QAC7E,sEAAsE;SACrE,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IAElD,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAE/E,OAAO;QACL,IAAI,EAAE,MAAM,CAAC,IAAI;QACjB,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,WAAW,EAAE,YAAY,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,MAAM;QACzD,WAAW,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM;QAChC,aAAa,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,MAAM;QACrE,OAAO,EAAE,MAAM,CAAC,OAAO,CAAC;QACxB,QAAQ,EAAE,MAAM,CAAC,QAAQ,CAAC;QAC1B,SAAS,EAAE,WAAW,CAAC,MAAM,CAAC,WAAW,EAAE,UAAU,CAAC;QACtD,WAAW,EAAE,MAAM,CAAC,WAAW;QAC/B,SAAS,EAAE,MAAM,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YAC3C,GAAG,EAAE,CAAC,CAAC,GAAG;YACV,KAAK,EAAE,cAAc,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG;YACrC,GAAG,EAAE,CAAC,CAAC,GAAG;SACX,CAAC,CAAC;QACH,UAAU,EAAE,YAAY,CAAC,MAAM,CAAC;QAChC,UAAU,EAAE,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QAC9F,QAAQ,EAAE,MAAM,CAAC,QAAQ;KAC1B,CAAC;AACJ,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,WAAW,CAClB,WAAgC,EAChC,UAA+B;IAE/B,MAAM,GAAG,GAA0B,EAAE,CAAC;IACtC,KAAK,MAAM,CAAC,IAAI,WAAW,EAAE,CAAC;QAC5B,IAAI,CAAC,CAAC,IAAI,KAAK,OAAO,IAAI,CAAC,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC9C,SAAS;QACX,CAAC;QACD,8EAA8E;QAC9E,kDAAkD;QAClD,IAAI,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC;YACf,SAAS;QACX,CAAC;QACD,GAAG,CAAC,IAAI,CAAC;YACP,IAAI,EAAE,CAAC,CAAC,IAAI;YACZ,IAAI,EAAE,CAAC,CAAC,IAAI;YACZ,2EAA2E;YAC3E,0EAA0E;YAC1E,sDAAsD;YACtD,KAAK,EAAE,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE;SACvC,CAAC,CAAC;IACL,CAAC;IACD,iFAAiF;IACjF,0EAA0E;IAC1E,OAAO,CAAC,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC;AACzE,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,YAAY,CAAC,MAAqB;IACzC,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QAChC,8EAA8E;QAC9E,6DAA6D;QAC7D,KAAK,MAAM,EAAE,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YAC7B,MAAM,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QAC5C,CAAC;IACH,CAAC;IAED,MAAM,UAAU,GAAG,IAAI,GAAG,EAA8B,CAAC;IACzD,MAAM,IAAI,GAAG,CAAC,QAAgB,EAAE,KAAuB,EAAQ,EAAE;QAC/D,MAAM,IAAI,GAAG,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACtC,IAAI,IAAI,EAAE,CAAC;YACT,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACnB,CAAC;aAAM,CAAC;YACN,UAAU,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;QACpC,CAAC;IACH,CAAC,CAAC;IAEF,MAAM,OAAO,GAAG,IAAI,GAAG,EAAU,CAAC;IAClC,KAAK,MAAM,CAAC,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;QAC9B,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAClB,IAAI,CAAC,CAAC,CAAC,QAAQ,EAAE;YACf,EAAE,EAAE,CAAC,CAAC,EAAE;YACR,IAAI,EAAE,CAAC,CAAC,IAAI;YACZ,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC;YAC5B,MAAM,EAAE,CAAC,CAAC,CAAC,QAAQ;SACpB,CAAC,CAAC;IACL,CAAC;IACD,KAAK,MAAM,CAAC,EAAE,EAAE,KAAK,CAAC,IAAI,MAAM,EAAE,CAAC;QACjC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;YACrB,IAAI,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;QAClD,CAAC;IACH,CAAC;IAED,+EAA+E;IAC/E,2EAA2E;IAC3E,sEAAsE;IACtE,KAAK,MAAM,MAAM,IAAI,UAAU,CAAC,MAAM,EAAE,EAAE,CAAC;QACzC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;IAC3E,CAAC;IACD,OAAO,CAAC,GAAG,UAAU,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;AAC/E,CAAC"} \ No newline at end of file diff --git a/dist/datamodel/parser/ProjectParser.d.ts b/dist/datamodel/parser/ProjectParser.d.ts index 463044c..1accb3c 100644 --- a/dist/datamodel/parser/ProjectParser.d.ts +++ b/dist/datamodel/parser/ProjectParser.d.ts @@ -1,6 +1,7 @@ import { type ParseWarning } from './ParseWarning.js'; /** A member file (or folder) of the project. */ export interface ProjectFile { + /** POSIX path relative to the project ROOT, not to the parent entity. */ path: string; isFolder: boolean; /** Label UUIDs assigned to this file (e.g. 'design'). */ @@ -12,19 +13,88 @@ export interface ProjectLabel { id: string; category: string; name: string; + /** + * Whether MATLAB owns this label. The seven Classification labels ship with + * every project and are marked read-only in the store; a label the project + * itself added is not, which is the only thing distinguishing the two. + */ + readOnly: boolean; } /** A project-to-project reference. */ export interface ProjectReference { id: string; name: string | null; } +/** + * A registered entry point: a shortcut, or a file MATLAB runs on open/close. + * + * One store collection covers all three, separated only by `kind`, which is why + * they are one type here. `prev` is deliberately NOT exposed: the store encodes run + * order as a linked list over these entries, and `entryPoints` is already in that + * order (see `orderEntryPoints`), so a consumer never has to know the mechanism. + */ +export interface ProjectEntryPoint { + /** The entry point's own UUID (its pointer `location`). */ + id: string; + name: string; + /** Project-root-relative path. May name a FOLDER — a shortcut can open one. */ + file: string; + /** 'Basic' (a shortcut), 'StartUp', 'Shutdown', or whatever a newer release writes. */ + kind: string; + /** Whether MATLAB shows this in its Shortcuts gallery. Startup/shutdown files are hidden. */ + visible: boolean; + /** The EntryPointGroup id; '' when ungrouped (the store spells that 'default'). */ + groupId: string; +} +/** A named group in the Shortcuts gallery. */ +export interface ProjectEntryPointGroup { + id: string; + name: string; +} +/** + * A folder (or file) the project designates for a particular purpose — simulation + * cache, code generation, startup folder, dependency cache. + * + * `key` is the store's own name for the purpose (`SimulinkCacheFolder`, + * `SimulinkCodeGenFolder`, `ProjectStartupFolder`, `DependencyCacheFile`) and is + * NOT translated here: a newer release may designate something this version has no + * label for, and passing the raw key on lets a host show it rather than drop it. + * Note the last of those four names a FILE, so these are not all folders. + */ +export interface ProjectWorkingFolder { + key: string; + /** Project-root-relative path. */ + ref: string; +} export interface ParsedProject { name: string; + /** + * The store's declared `MetadataType` — how the project's metadata is laid out on + * disk. '' when the store declares none and the layout could not be inferred. + * Two are read: `fixedPathV2` and `distributed`. Anything else is reported through + * `warnings` rather than read, because guessing at a layout produces a project + * that looks complete and is not. + */ + format: string; files: ProjectFile[]; + /** + * Folders added to the MATLAB path, project-root-relative. The project ROOT + * itself is spelled '' — the store records it as `Ref=""`, and it is a real entry + * (usually the first one MATLAB adds), so dropping it would under-report the path. + */ pathFolders: string[]; /** The catalog of labels defined in the project. */ labels: ProjectLabel[]; references: ProjectReference[]; + /** + * Shortcuts and startup/shutdown files, in this order: startup files in RUN + * order, then shutdown files in run order, then everything else (shortcuts) in + * store order. Run order is the store's, which is meaningful — MATLAB runs the + * files top-down — and is not recoverable from any other field. + */ + entryPoints: ProjectEntryPoint[]; + entryPointGroups: ProjectEntryPointGroup[]; + workingFolders: ProjectWorkingFolder[]; /** * What could not be read, empty when everything could. ALWAYS an array: this * reader has the channel, so a caller may read `.length` without guarding, and diff --git a/dist/datamodel/parser/ProjectParser.d.ts.map b/dist/datamodel/parser/ProjectParser.d.ts.map index f2be42d..e7a52e5 100644 --- a/dist/datamodel/parser/ProjectParser.d.ts.map +++ b/dist/datamodel/parser/ProjectParser.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"ProjectParser.d.ts","sourceRoot":"","sources":["../../../src/datamodel/parser/ProjectParser.ts"],"names":[],"mappings":"AAGA,OAAO,EAAY,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEhE,gDAAgD;AAChD,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,yDAAyD;IACzD,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,yEAAyE;AACzE,MAAM,WAAW,YAAY;IAC3B,+EAA+E;IAC/E,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,sCAAsC;AACtC,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,WAAW,EAAE,CAAC;IACrB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,oDAAoD;IACpD,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,UAAU,EAAE,gBAAgB,EAAE,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,EAAE,YAAY,EAAE,CAAC;CAC1B;AAyCD;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,EAAE,MAAM,GAAG,aAAa,CA2H9F"} \ No newline at end of file +{"version":3,"file":"ProjectParser.d.ts","sourceRoot":"","sources":["../../../src/datamodel/parser/ProjectParser.ts"],"names":[],"mappings":"AAGA,OAAO,EAAY,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEhE,gDAAgD;AAChD,MAAM,WAAW,WAAW;IAC1B,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,yDAAyD;IACzD,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,yEAAyE;AACzE,MAAM,WAAW,YAAY;IAC3B,+EAA+E;IAC/E,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,sCAAsC;AACtC,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,iBAAiB;IAChC,2DAA2D;IAC3D,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,uFAAuF;IACvF,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,OAAO,EAAE,OAAO,CAAC;IACjB,mFAAmF;IACnF,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,8CAA8C;AAC9C,MAAM,WAAW,sBAAsB;IACrC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,kCAAkC;IAClC,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,WAAW,EAAE,CAAC;IACrB;;;;OAIG;IACH,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,oDAAoD;IACpD,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,UAAU,EAAE,gBAAgB,EAAE,CAAC;IAC/B;;;;;OAKG;IACH,WAAW,EAAE,iBAAiB,EAAE,CAAC;IACjC,gBAAgB,EAAE,sBAAsB,EAAE,CAAC;IAC3C,cAAc,EAAE,oBAAoB,EAAE,CAAC;IACvC;;;;OAIG;IACH,QAAQ,EAAE,YAAY,EAAE,CAAC;CAC1B;AA8GD;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,EAAE,MAAM,GAAG,aAAa,CAqJ9F"} \ No newline at end of file diff --git a/dist/datamodel/parser/ProjectParser.js b/dist/datamodel/parser/ProjectParser.js index 0b0c44d..3ff58ad 100644 --- a/dist/datamodel/parser/ProjectParser.js +++ b/dist/datamodel/parser/ProjectParser.js @@ -2,12 +2,52 @@ import { readProjectXml } from './XmlReader.js'; import { reasonOf } from './ParseWarning.js'; const PROJECT_PREFIX = 'resources/project/'; +/** The store manifest, which declares the layout the rest of the store is in. */ +const MANIFEST = 'Project.xml'; +/** The layouts this reader walks. See `ParsedProject.format`. */ +const FIXED_PATH_V2 = 'fixedPathV2'; +const DISTRIBUTED = 'distributed'; +/** + * Collection types whose `type="Reference"` children are NOT project references. + * + * A project->project reference and a path folder and a working folder are all + * spelled `type="Reference"`, distinguished only by which collection they sit in. + * So this is a DENY-list rather than an allow-list of reference collections, and + * deliberately: the collection holding real references is spelled differently + * across releases, and an allow-list missing a spelling silently drops references, + * whereas this list — the collections whose meaning we positively model — cannot. + * Before it existed, every WorkingFolders entry was reported as a project + * reference, which INVENTED references a project does not have. + */ +const NOT_REFERENCE_COLLECTIONS = new Set([ + 'ProjectPath', + 'WorkingFolders', + 'Categories', + 'Files', + 'EntryPoints', + 'EntryPointGroups', + 'Info', +]); function toArray(v) { if (v === undefined || v === null) { return []; } return Array.isArray(v) ? v : [v]; } +function emptyResult(name, warnings) { + return { + name, + format: '', + files: [], + pathFolders: [], + labels: [], + references: [], + entryPoints: [], + entryPointGroups: [], + workingFolders: [], + warnings, + }; +} /** * Parse a MATLAB/Simulink Project content store. * @@ -19,14 +59,7 @@ function toArray(v) { */ export function parseProject(files, projectName) { const warnings = []; - const result = { - name: projectName, - files: [], - pathFolders: [], - labels: [], - references: [], - warnings, - }; + const result = emptyResult(projectName, warnings); try { // Index every parseable Info doc by its relpath (project-relative). const index = new Map(); @@ -47,64 +80,99 @@ export function parseProject(files, projectName) { // Every collection below reads out of this index, so an empty one means the // whole result is empty — and a `.prj` always has a store, so reaching here // is either the wrong kind of file or a store that did not survive its trip. + // + // Except for one case worth naming separately: a store MATLAB wrote in its + // `matlab.toml` format holds no XML at all, so it lands here looking exactly + // like damage. It is not damaged, it is a format this reader does not read, + // and a user told "nothing readable" would go looking for a corrupt file. + const toml = Object.keys(files).find((k) => k.startsWith(PROJECT_PREFIX) && k.slice(PROJECT_PREFIX.length).endsWith('matlab.toml')); + warnings.push(toml + ? { + // 'source-empty' rather than a code of its own: the code is the kind of + // loss (the source opened and held nothing this reader recognizes) and + // the message is which, per ParseWarningCode's note that the codes are + // about containers and parts and not about any one format. + code: 'source-empty', + message: 'This project stores its metadata as matlab.toml, which this viewer cannot read yet, ' + + 'so this project reads as empty. In MATLAB, Project Settings can save it as XML instead.', + part: toml, + } + : { + code: 'source-empty', + message: 'No readable project entries were found under resources/project/, ' + + 'so this project reads as empty.', + }); + return result; + } + // The manifest declares which layout the rest of the store is in. A store that + // declares one we cannot walk is reported and NOT guessed at: the collection + // readers below would find nothing in it and return a project that looks + // complete and empty, which is the one outcome a user cannot tell from a fact. + const declared = index.get(MANIFEST)?.['@_MetadataType'] ?? ''; + const layoutName = declared || inferLayout(index); + if (layoutName !== FIXED_PATH_V2 && layoutName !== DISTRIBUTED) { + // The name is still worth salvaging — it is what a host titles the view with, + // and it reads out of the index without knowing the layout. + result.name = salvageName(index) ?? projectName; warnings.push({ code: 'source-empty', - message: 'No readable project entries were found under resources/project/, ' + - 'so this project reads as empty.', + message: declared + ? `This project's metadata is stored as "${declared}", which this viewer cannot read yet, ` + + 'so this project reads as empty. In MATLAB, Project Settings can save it as multiple XML files instead.' + : 'The layout of this project store was not recognized, so this project reads as empty.', + part: PROJECT_PREFIX + MANIFEST, }); return result; } - // The `root/` directory holds the top-level entry pointers. - const rootEntities = readDir(index, 'root'); - // Project name: the def whose pointer has location="ProjectData" type="Info". + result.format = layoutName; + const layout = layoutName === FIXED_PATH_V2 ? fixedPathV2Layout(index) : distributedLayout(index); + const rootEntities = layout.roots(); + result.name = resolveNameFrom(rootEntities) ?? projectName; + // Collections are matched on type AND location. Two collections of one type can + // coexist — a real project carries both `location="Root" type="Files"` (its + // members) and `location="ALM" type="Files"` (artifact tracking) — so matching + // on type alone assigned the field twice and let store iteration order pick the + // winner. `Root` is the project's own; anything else belongs to a feature we do + // not model, and reading it as the member list is simply wrong. for (const ent of rootEntities) { - if (ent.pointer?.['@_location'] === 'ProjectData' && ent.pointer?.['@_type'] === 'Info') { - const name = ent.def?.['@_Name']; - if (name) { - result.name = name; - } + if (ent.location !== 'Root') { + continue; } - } - // Some stores also carry the name on a bare def whose - // pointer has no matching location; take it if we still have the fallback. - if (result.name === projectName) { - for (const ent of rootEntities) { - const name = ent.def?.['@_Name']; - if (name && !ent.pointer?.['@_type']) { - result.name = name; - break; - } + if (ent.type === 'Files') { + result.files = readFiles(layout, ent); } - } - for (const ent of rootEntities) { - const type = ent.pointer?.['@_type']; - if (type === 'Files') { - result.files = readFiles(index, ent.hash); + else if (ent.type === 'ProjectPath') { + result.pathFolders = readPathFolders(layout, ent); + } + else if (ent.type === 'Categories') { + result.labels = readCategories(layout, ent); + } + else if (ent.type === 'EntryPoints') { + result.entryPoints = orderEntryPoints(readEntryPoints(layout, ent)); } - else if (type === 'ProjectPath') { - result.pathFolders = readPathFolders(index, ent.hash); + else if (ent.type === 'EntryPointGroups') { + result.entryPointGroups = readEntryPointGroups(layout, ent); } - else if (type === 'Categories') { - result.labels = readCategories(index, ent.hash); + else if (ent.type === 'WorkingFolders') { + result.workingFolders = readWorkingFolders(layout, ent); } - else if (type === 'Reference') { - // A genuine project->project reference living directly in root. + } + for (const ent of rootEntities) { + // A genuine project->project reference living directly in root. + if (ent.type === 'Reference') { const ref = resolveReference(ent); if (ref) { result.references.push(ref); } + continue; } - } - // References may also live in their own top-level collection outside the - // ProjectPath collection. Scan any root entry whose dir contains - // type="Reference" children (but skip the ProjectPath collection itself). - for (const ent of rootEntities) { - if (ent.pointer?.['@_type'] === 'ProjectPath') { + // …or in its own collection. See NOT_REFERENCE_COLLECTIONS for why the set of + // collections to skip is spelled out rather than the set to read. + if (NOT_REFERENCE_COLLECTIONS.has(ent.type)) { continue; } - const children = readDir(index, ent.hash); - for (const child of children) { - if (child.pointer?.['@_type'] === 'Reference') { + for (const child of layout.children(ent)) { + if (child.type === 'Reference') { const ref = resolveReference(child); if (ref) { result.references.push(ref); @@ -126,15 +194,69 @@ export function parseProject(files, projectName) { code: 'source-unreadable', message: `The project store could not be read (${reasonOf(err)}), so this project reads as empty.`, }); - return { - name: projectName, - files: [], - pathFolders: [], - labels: [], - references: [], - warnings, - }; + return emptyResult(projectName, warnings); + } +} +/** + * Which layout a store with no manifest is in. + * + * Every store this reader has seen carries `Project.xml`, so this is the fallback + * for one that lost it: `root/` is the entry directory `fixedPathV2` and nothing + * else uses, and a `.type.` in a top-level name is `distributed`'s own spelling. + */ +function inferLayout(index) { + for (const key of index.keys()) { + if (key.startsWith('root/')) { + return FIXED_PATH_V2; + } + } + for (const key of index.keys()) { + if (!key.includes('/') && key.includes('.type.')) { + return DISTRIBUTED; + } + } + return ''; +} +/** + * The project name, for a store whose layout could not be walked. + * + * Kept separate from `resolveNameFrom` because it reads the index directly rather + * than the entity tree — there is no tree in this case — and every layout seen so + * far spells the document holding the name recognizably enough to find it without + * knowing which layout this is. + */ +function salvageName(index) { + for (const [key, info] of index.entries()) { + if (key === MANIFEST) { + continue; + } + // `distributed` names it ProjectData.type.Info.xml; `fixedPathV2` puts it in a + // hash-named def whose pointer says location="ProjectData". + if (key.includes('ProjectData') && info['@_Name']) { + return info['@_Name']; + } + } + return null; +} +/** The project name from the root entities, or null to keep the filename fallback. */ +function resolveNameFrom(rootEntities) { + for (const ent of rootEntities) { + if (ent.location === 'ProjectData' && ent.type === 'Info') { + const name = ent.def?.['@_Name']; + if (name) { + return name; + } + } } + // Some stores also carry the name on a bare def whose + // pointer has no type at all. + for (const ent of rootEntities) { + const name = ent.def?.['@_Name']; + if (name && !ent.type) { + return name; + } + } + return null; } /** * One `` document, or null when there is nothing here to index. @@ -181,49 +303,141 @@ function parseInfo(content, relPath, warnings) { } return info; } +// --------------------------------------------------------------------------------- +// The two layouts. /** - * Read all pointer/def entities in a directory (relative to resources/project/). - * Groups files by hash/stem, treating `p`/`_sp` as pointers and `d`/`_sd` as defs. + * `fixedPathV2`: every entity is a POINTER/def pair of documents, and every child + * directory sits at the TOP of the store named by an opaque hash — flat, however + * deep the logical nesting goes, which is what "fixed path" means. The top-level + * entities live in `root/`. */ -function readDir(index, dir) { - if (!dir) { - return []; - } - const prefix = dir + '/'; - const byHash = new Map(); - for (const [relPath, info] of index.entries()) { - if (!relPath.startsWith(prefix)) { - continue; - } - const rest = relPath.slice(prefix.length); - // Only immediate children (no further nesting). - if (rest.includes('/')) { - continue; - } - const parsed = parseChildName(rest); - if (!parsed) { - continue; +function fixedPathV2Layout(index) { + const readDir = (dir) => { + if (!dir) { + return []; } - const { hash, isPointer } = parsed; - let ent = byHash.get(hash); - if (!ent) { - ent = { hash, pointer: null, def: null }; - byHash.set(hash, ent); + const prefix = dir + '/'; + const byHash = new Map(); + for (const [relPath, info] of index.entries()) { + if (!relPath.startsWith(prefix)) { + continue; + } + const rest = relPath.slice(prefix.length); + // Only immediate children (no further nesting). + if (rest.includes('/')) { + continue; + } + const parsed = parseChildName(rest); + if (!parsed) { + continue; + } + const { hash, isPointer } = parsed; + let pair = byHash.get(hash); + if (!pair) { + pair = { pointer: null, def: null }; + byHash.set(hash, pair); + } + if (isPointer) { + pair.pointer = info; + } + else { + pair.def = info; + } } - if (isPointer) { - ent.pointer = info; + // Store order (Map preserves insertion), NOT sorted by hash: the hashes are + // opaque, so sorting on them would impose an order that means nothing while + // discarding the one the store was written in — which is the fallback order for + // entry points whose run-order chain is absent. + return [...byHash.entries()].map(([hash, pair]) => ({ + dir: hash, + location: pair.pointer?.['@_location'] ?? '', + type: pair.pointer?.['@_type'] ?? '', + def: pair.def, + })); + }; + return { roots: () => readDir('root'), children: (e) => readDir(e.dir) }; +} +/** + * `distributed`: no pointer documents at all. An entity's location and type are its + * FILENAME — `.type..xml` for the def, `.type./` + * for the child directory — and directories nest the way the entities do. The + * top-level entities sit directly in the store root. + * + * A collection may exist as a directory with no def document beside it + * (`Root.type.Files/` without `Root.type.Files.xml`), so entities are discovered + * from directory names as well as from documents, and a missing def is normal here + * rather than a loss. + */ +function distributedLayout(index) { + const readDir = (dir) => { + const prefix = dir ? dir + '/' : ''; + const byStem = new Map(); + for (const [relPath, info] of index.entries()) { + if (!relPath.startsWith(prefix)) { + continue; + } + const rest = relPath.slice(prefix.length); + if (!rest) { + continue; + } + const slash = rest.indexOf('/'); + if (slash === -1) { + // An immediate document: the entity's def. + const stem = rest.slice(0, -'.xml'.length); + if (!byStem.has(stem)) { + byStem.set(stem, info); + } + else if (byStem.get(stem) === null) { + byStem.set(stem, info); + } + } + else { + // Something deeper: the first segment is an entity's child DIRECTORY, and + // is how a collection with no def document of its own is found at all. + const stem = rest.slice(0, slash); + if (!byStem.has(stem)) { + byStem.set(stem, null); + } + } } - else { - ent.def = info; + const out = []; + for (const [stem, def] of byStem) { + const split = splitTypedName(stem); + if (!split) { + continue; + } + out.push({ dir: prefix + stem, location: split.location, type: split.type, def }); } + return out; + }; + return { roots: () => readDir(''), children: (e) => readDir(e.dir) }; +} +/** + * Split a `distributed` stem into its location and type. + * + * The LAST `.type.` is the separator, not the first: a location is a filename and + * may contain the marker itself (a project file called `foo.type.File` yields the + * stem `foo.type.File.type.File`), and splitting on the first would truncate it. + * Null for a stem carrying no marker — `Project.xml` and the store's own uuid + * document are both of that shape, and neither is an entity. + */ +function splitTypedName(stem) { + const marker = '.type.'; + const at = stem.lastIndexOf(marker); + if (at <= 0) { + return null; } - return [...byHash.values()]; + const type = stem.slice(at + marker.length); + if (!type || type.includes('.')) { + return null; + } + return { location: stem.slice(0, at), type }; } /** - * Given a child filename like `8AEH..._sp.xml` or `qaw0...p.xml`, return the - * hash (stem before the suffix) and whether it is a pointer. Null when the stem - * carries none of the four recognized suffixes — a file in the store that is not - * half of a pointer/def pair, which readDir skips. + * Given a `fixedPathV2` child filename like `8AEH..._sp.xml` or `qaw0...p.xml`, + * return the hash (stem before the suffix) and whether it is a pointer. Null when + * the stem carries none of the four recognized suffixes — a file in the store that + * is not half of a pointer/def pair, which readDir skips. * * PRECONDITION: `name` ends in `.xml`. parseProject's index only admits `.xml` * paths, and readDir only asks about entries of that index, so re-checking here @@ -245,43 +459,48 @@ function parseChildName(name) { } return null; } +// --------------------------------------------------------------------------------- +// The collections. /** * Read the Files collection. Members are File entities; each File entity's own - * dir holds its children as pointer/def pairs: a DIR_SIGNIFIER marks a folder, - * and any nested type="File" children are themselves project files (recurse). + * dir holds its children as entities: a DIR_SIGNIFIER marks a folder, and any + * nested type="File" children are themselves project files (recurse). */ -function readFiles(index, filesHash) { +function readFiles(layout, collection) { const out = []; const seen = new Set(); - const members = readDir(index, filesHash); - for (const member of members) { - collectFile(index, member, out, seen); + for (const member of layout.children(collection)) { + collectFile(layout, member, '', out, seen); } return out; } -function collectFile(index, entity, out, seen) { - if (entity.pointer?.['@_type'] !== 'File') { +function collectFile(layout, entity, parentPath, out, seen) { + if (entity.type !== 'File') { return; } - const path = entity.pointer['@_location']; - if (!path) { + // A file entity's location is relative to its PARENT, not to the project root — + // the store nests them the way the filesystem does. Read verbatim, every file + // inside a folder reported the bare name that a sibling at the root would, so + // `utils/helper.m` and a root-level `helper.m` were indistinguishable. + const name = entity.location; + if (!name) { return; } + const path = parentPath ? `${parentPath}/${name}` : name; // Guard against cycles / repeated hashes. - if (seen.has(entity.hash)) { + if (seen.has(entity.dir)) { return; } - seen.add(entity.hash); - const children = readDir(index, entity.hash); + seen.add(entity.dir); + const children = layout.children(entity); let isFolder = false; const labels = []; const fileChildren = []; for (const child of children) { - const ctype = child.pointer?.['@_type']; - if (ctype === 'DIR_SIGNIFIER') { + if (child.type === 'DIR_SIGNIFIER') { isFolder = true; } - else if (ctype === 'File') { + else if (child.type === 'File') { fileChildren.push(child); } } @@ -292,7 +511,7 @@ function collectFile(index, entity, out, seen) { out.push({ path, isFolder, labels: dedupe(labels) }); // Recurse into nested File children (folder contents). for (const child of fileChildren) { - collectFile(index, child, out, seen); + collectFile(layout, child, path, out, seen); } } /** Collect all Label UUIDs from an Info def's nested