Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions docs/tutorials/first-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,14 @@ cat office.idf

```idf
Zone,
Open Plan, !- Name
, !- Direction of Relative North
, !- X Origin
, !- Y Origin
, !- Z Origin
, !- Type
1, !- Multiplier
2.7; !- Ceiling Height
Open Plan, !- Name
, !- Direction of Relative North
, !- X Origin
, !- Y Origin
, !- Z Origin
, !- Type
1, !- Multiplier
2.7; !- Ceiling Height
```

Your zone is called `Open Plan`, and so is the wall's `Zone Name` further down
Expand Down
4 changes: 2 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@
"node": ">=20"
},
"idfkit": {
"conformance": "conformance-2026.10",
"governance": "governance-2026.14"
"conformance": "conformance-2026.11",
"governance": "governance-2026.15"
},
"dependencies": {
"@idfkit/schemas": "0.0.0"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@
* This is not a version number and it is not compared to one. Two installed libraries agree on the
* formats when they declare the same level, whatever their own versions say (FR-025).
*/
export const CONFORMANCE_LEVEL = 'conformance-2026.10';
export const CONFORMANCE_LEVEL = 'conformance-2026.11';
138 changes: 136 additions & 2 deletions packages/core/src/document.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { Schema, SlimType } from '@idfkit/schemas';

import { IdfCollection } from './collection.js';
import { DATA, KEY, NAME, OWNER, SHAPE, SOURCE } from './internal.js';
import { DATA, KEY, NAME, ORIGIN, OWNER, SHAPE, SOURCE } from './internal.js';
import { IdfObject, type FieldValues, type ObjectOwner, type StoredValue } from './object.js';
import type { PreservedSource } from './preserve/source.js';
import { isUntouched, originOf, type PreservedSource } from './preserve/source.js';
import { derivedOf, renderStatement } from './preserve/write.js';
import { ReferenceGraph } from './references.js';
import type { Region } from './syntax/region.js';
import { preservingOptions, type WriteIdfOptions } from './write/idf.js';
import type { AnyTypeMap, ObjectOf, TypeNameOf, UntypedMap, ValuesOf } from './typemap.js';

/**
Expand Down Expand Up @@ -301,6 +304,137 @@ export class IdfDocument<M extends AnyTypeMap = UntypedMap> implements ObjectOwn
for (const collection of this.#collections.values()) yield* collection;
}

/**
* Every object a preserving write will write afresh rather than reproduce.
*
* Empty for a document read with `preserveFormatting` and not edited since. Every object for a
* document read without it, because there is nothing to reproduce.
*
* `rawText` answers whether a write will preserve at all. This answers how many objects it will
* REWRITE, and it is the part a consumer cannot work out for itself: a rename clears the record on
* every object that referred to the renamed one, so counting from your own edit log reports one
* where the answer is nine.
*
* **It is not "everything that will differ", and a removal is the case that separates the two.**
* An object removed from the document is no longer in it to be yielded, so this can return nothing
* for a write that changes the file. A consumer treating an empty result as "the file is
* unchanged" would be wrong on every removal. To ask whether the file will differ at all, compare
* the write with `rawText`; ask this for how much of it is being written afresh.
*
* A generator, so listing what is about to be reformatted is as easy as counting it:
*
* ```ts
* const changed = [...document.changedObjects()];
* if (changed.length > 0) warn(`Saving will rewrite ${changed.length} objects.`);
* ```
*/
*changedObjects(): Generator<IdfObject> {
for (const obj of this.objects()) {
if (!isUntouched(obj, this.#source)) yield obj;
}
}

/**
* Where an object's characters sit in {@link rawText}, or `undefined` if they sit nowhere.
*
* `undefined` for an object added since the read, for a document read without preservation, and
* for one read from the object notation, which has no statements to point at and preserves
* all-or-nothing.
*
* This is what makes {@link changedObjects} usable. Turning an edit into the smallest possible
* change to a file takes three things: WHICH objects will be rewritten, WHAT text each becomes,
* and WHERE the old one was. Without the third a consumer has to write the whole file and diff
* it, which is the work `changedObjects` exists to avoid.
*
* The range is where the object WAS, and stays answerable after it changes. That is the case it
* is for: the objects worth locating are the ones being rewritten.
*
* ```ts
* for (const obj of document.changedObjects()) {
* const at = document.regionOf(obj);
* if (at === undefined) continue; // added since the read; there is no old text to replace
* edits.push({ range: at, newText: replacementFor(obj) });
* }
* ```
*
* **Where the replacement text comes from is not settled by this method, and `writeObject` is
* not the answer.** A preserving write hands that function the author's own per-field comments,
* which are internal, so calling it with options built by hand produces text that differs from
* what {@link writeIdf} would have produced for the same object: the author's units and notes
* come back as generated labels. A consumer that needs the two to agree has to take the whole
* file from `writeIdf`. This method locates the edit; producing its text for a single object is
* a gap that is open, and it is recorded rather than papered over.
*
* The end of the range is the WRITER's, which is not always the semicolon: a comment on the
* terminator's own line is that statement's last field's comment and a preserving write replaces
* it. A range that stopped at the semicolon would leave it behind, describing a field that had
* just moved.
*
* Offsets, not a line and column: `Region` carries the conversion, and a consumer that wants one
* has the text to compute it from, while going the other way costs a scan.
*/
regionOf(obj: IdfObject): Region | undefined {
const source = this.#source;
const at = originOf(obj, source);
if (source === undefined || at === undefined) return undefined;
// The object notation records one anchor per object and no statement, so there is nothing here
// to point at. Preservation is all-or-nothing there and a per-object range would be a fiction.
const statement = source.layer.statements[at];
if (statement === undefined) return undefined;
// The END is the writer's, not the statement's. A comment on the terminator's own line is that
// statement's last field's comment and the preserving write replaces it; a range stopping at
// the semicolon would leave it behind, on a line describing a field that had just moved.
return {
start: statement.region.start,
end: derivedOf(source).ends[at] ?? statement.region.end,
};
}

/**
* One object, rendered exactly as a preserving write would render it.
*
* The text that belongs in the range {@link regionOf} returns, so the two compose into an edit
* that leaves the file byte for byte where {@link writeIdf} would have left it. `undefined` for
* an object the retained source does not hold, which is the same set `regionOf` declines.
*
* ```ts
* for (const obj of document.changedObjects()) {
* const at = document.regionOf(obj);
* const text = document.renderObject(obj);
* if (at === undefined || text === undefined) continue; // added since the read
* edits.push({ range: at, newText: text });
* }
* ```
*
* `writeObject` is not this, and that is the reason this exists. A preserving write hands that
* function the author's own per-field annotations, which are internal, so calling it with options
* built by hand comes back with the author's units and notes as generated labels: `!- North Axis
* {deg}` becomes `!- North Axis`. That is a unit lost from an engineering model by an editor
* asked to save a file, and no doc comment is a good enough guard against it.
*
* `fieldComments` is the ONLY option, because it is the only one a preserving write honours.
* `indent`, `commentColumn`, `ordering` and `versionFirst` are refused by {@link writeIdf}
* alongside `preserveFormatting`, and `comments: false` and `compressed` defeat preservation and
* send the whole document down the formatting path instead. Accepting any of them here would let
* a caller render one object on terms the surrounding file was not written on, which is the exact
* divergence this method exists to prevent.
*
* No trailing line break: the range this fills ends at the terminator, or at the comment on that
* line, and the break after it is the first character of what separates one object from the next,
* which a preserving write leaves in place.
*/
renderObject(
obj: IdfObject,
options: Pick<WriteIdfOptions, 'fieldComments'> = {}
): string | undefined {
const source = this.#source;
const at = originOf(obj, source);
if (source === undefined || at === undefined) return undefined;
// The options `writeIdf` resolves for its preserving branch, resolved by the same function, so
// the two cannot disagree about the bytes.
return renderStatement(source, at, preservingOptions(options));
}

/** Reference targets that no object provides. */
danglingReferences(): ReturnType<ReferenceGraph['dangling']> {
const valid = new Set<string>();
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,19 @@ export const KEY = Symbol('idfkit.key');
* new value and back again is unchanged by comparison and touched in truth.
*/
export const SOURCE = Symbol('idfkit.source');
/**
* Which statement an object was READ from, kept whether or not it has since changed.
*
* `SOURCE` is cleared the moment an object is touched, because its absence is what marks the
* object as needing to be rewritten. That makes it useless for saying where the old characters
* were, which is exactly the question a consumer building a minimal edit has to ask about a
* CHANGED object. This is the same number, recorded once and never cleared.
*
* Deliberately untagged, as `SOURCE` above is. `stripInternal` drops a declaration whose JSDoc
* carries the tag, and `object.ts` declares a property keyed on this symbol, so tagging it emits a
* `.d.ts` that references a symbol its own module no longer declares and the build stops. Being
* absent from the package index is what makes this module internal, and that part holds.
*
* The tag is matched as TEXT anywhere in the comment, so naming it here would strip this too.
*/
export const ORIGIN = Symbol('idfkit.origin');
4 changes: 3 additions & 1 deletion packages/core/src/object.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { SlimField, SlimType } from '@idfkit/schemas';

import { ExtensibleList } from './extensible.js';
import { DATA, KEY, NAME, OWNER, SHAPE, SOURCE } from './internal.js';
import { DATA, KEY, NAME, ORIGIN, OWNER, SHAPE, SOURCE } from './internal.js';
import { shapeFor, type ObjectShape } from './shape.js';

/** A scalar field value. `undefined` means the field is absent. */
Expand Down Expand Up @@ -56,6 +56,7 @@ export class IdfObject {
declare [KEY]: string;
/** Index into the document's preserved anchors, or `undefined` once anything has changed this. */
declare [SOURCE]: number | undefined;
declare [ORIGIN]: number | undefined;

/**
* Objects are built through `IdfObject.create`, never `new`, because each
Expand Down Expand Up @@ -83,6 +84,7 @@ export class IdfObject {
// it exists (FR-007). `clone` builds through here too, which is why a copy is touched as well:
// it is a different object from the one the characters describe.
Object.defineProperty(obj, SOURCE, { value: undefined, writable: true });
Object.defineProperty(obj, ORIGIN, { value: undefined, writable: true });

for (const [field, value] of Object.entries(values)) {
if (value === undefined || value === null) continue;
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/parse/idf.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Schema, SlimType } from '@idfkit/schemas';

import { IdfDocument } from '../document.js';
import { SOURCE } from '../internal.js';
import { ORIGIN, SOURCE } from '../internal.js';
import type { ExtensibleGroup, FieldValues, IdfObject, StoredValue } from '../object.js';
import { statementIndexes } from '../preserve/source.js';
import { layerCollector } from '../syntax/layer.js';
Expand Down Expand Up @@ -143,6 +143,9 @@ export function parseIdf<M extends AnyTypeMap = UntypedMap>(
if (at !== undefined) {
anchors[at] = built;
built[SOURCE] = at;
// The same number, kept past the first edit. `SOURCE` goes when the object is touched,
// which is what marks it for rewriting; this one answers where its characters WERE.
built[ORIGIN] = at;
}

// Reported after the object is built, never instead of building it: a value of the wrong
Expand Down
21 changes: 20 additions & 1 deletion packages/core/src/preserve/source.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SOURCE } from '../internal.js';
import { ORIGIN, SOURCE } from '../internal.js';
import type { IdfObject } from '../object.js';
import type { RawObject } from '../parse/lexer.js';
import type { SyntaxLayer } from '../syntax/layer.js';
Expand Down Expand Up @@ -77,6 +77,25 @@ export function isWholeDocumentUntouched(
return true;
}

/**
* The statement an object was read from, or `undefined` if this source did not read it.
*
* The identity check is the one `isUntouched` makes below, for the same reason: an object carrying
* an index from a file it is no longer in would otherwise be answered from this one. It is stated
* once here because `regionOf` and `renderObject` both decline exactly this set, and a rule two
* methods share is a rule one of them will eventually be fixed without.
*
* Reads `ORIGIN`, not `SOURCE`: the question is where the characters WERE, which stays answerable
* after the object changes, and changing it is what clears `SOURCE`.
*
* @internal
*/
export function originOf(obj: IdfObject, source: PreservedSource | undefined): number | undefined {
if (source === undefined) return undefined;
const at = obj[ORIGIN];
return at !== undefined && source.anchors[at] === obj ? at : undefined;
}

export function isUntouched(obj: IdfObject, source: PreservedSource | undefined): boolean {
if (source === undefined) return false;
const at = obj[SOURCE];
Expand Down
Loading
Loading