From 69c8b5df279045fce24b31161b53334f6d6f3187 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 4 Sep 2026 14:34:52 -0400 Subject: [PATCH] Document the language service, and the gap it leaves in Python A capability page for `idf-language-service`, the answers an editor needs about IDF text: which statement the cursor is in, what may be written here, what this field means, where this name is declared, which characters a finding is about. Reading a model answers none of them, because by the time a document exists the text has been forgotten. The page is language-neutral prose with the code in one language and an explicit rendered absence in the other, which is the remedy Principle III provides rather than an exception to Principle IV. It carries `parity("idf-language-service")`, and that macro fails the site build when its argument resolves to nothing, which is what makes the gap impossible to publish as an empty panel. Verified by removing the id and watching the build abort, then restoring it. Python is `absent` with `absence_kind = "never"`, and the notice says so in those terms: a permanent boundary, no issue tracking it, and a constitutional amendment rather than a ledger edit to move it. The reason is on the page. The answers are byte-offset arithmetic, and a second implementation of that arithmetic is the drift surface the corpus is least able to police, since it compares findings on (code, line, typeName) and never on a column. Two implementations could disagree about a position for a long time with no gate noticing. The editor extension needs no port either: its existing Python server keeps serving Python source, and a second server serves IDF text. parity.md and naming-map.md are regenerated at governance-2026.11. The parity page had not been rendered since the ledger gained the capability, so the notice linked to an anchor that did not exist and the tier was stated nowhere the reader could reach. Both are generated artifacts, not hand edits. The governance level moves to governance-2026.11. The conformance, docs and library levels do not. The two TypeScript snippets are checked by nothing in this repository, and that is a gap rather than a decision. Every non-vendored snippet here is Python, checked by pyright, and the only checked TypeScript tree is docs/snippets/js/, which sync_js_artifacts.py compares byte for byte against the pinned docs release and which is never hand-edited. Both files type-check clean under strict against the live sources and are prettier-clean, proven by hand rather than by a gate. Closing it properly means authoring them in idfkit-js/docs-snippets/, publishing a docs artifact and bumping [tool.idfkit.docs], which is a release cycle rather than an edit. --- docs/explanation/idf-language-service.md | 209 +++++++ docs/explanation/naming-map.md | 572 ++++++++++++++++-- docs/explanation/parity.md | 153 ++--- .../explanation/idf_language_service.ts | 57 ++ .../idf_language_service_translation.ts | 180 ++++++ mkdocs.yml | 1 + pyproject.toml | 2 +- 7 files changed, 1041 insertions(+), 133 deletions(-) create mode 100644 docs/explanation/idf-language-service.md create mode 100644 docs/snippets/explanation/idf_language_service.ts create mode 100644 docs/snippets/explanation/idf_language_service_translation.ts diff --git a/docs/explanation/idf-language-service.md b/docs/explanation/idf-language-service.md new file mode 100644 index 0000000..8320f01 --- /dev/null +++ b/docs/explanation/idf-language-service.md @@ -0,0 +1,209 @@ +--- +title: A language service for IDF text +description: The five answers an editor needs about IDF text, why they are computed in one language only, and what that costs a reader in the other. +--- + +# A language service for IDF text + +*This page is about the JavaScript library. The Python library has no +counterpart and will not acquire one; the reason is below.* + +An editor holding a model file asks questions a reader of that file never asks. +Which statement is the cursor in. What may be written at this position. What +does this field mean. Where is this name declared. Which characters is this +finding about. + +Reading the file answers none of them. A document is a set of objects with +values, and by the time it exists the text has been forgotten: nothing in it +records that the zone name was written at offset 4812, so nothing built on it +can underline the value that is wrong. These are questions about the text, and +they are answered from the text. + +{{ parity("idf-language-service") }} + +## The syntax layer and the five answers + +Two pieces, split by who pays for them. + +The **syntax layer** ships in the package everyone installs. `scanIdf` reads +the text once and records where every statement, every field and every comment +was written, as half-open offsets into the string; `classify` walks that layer +and yields one token per meaningful span, filling the gaps between them so that +every character of the file is covered exactly once. It is in the core package +because reading already scans the text and a source-preserving writer will read +the layer too. + +The **five answers** ship as `@idfkit/language`, an opt-in package installed by +name and reached as `idfkit/language`. Installing the library under its shared +name places none of it on disk, deliberately: everything in the core package is +carried by everyone who reads a model, and most of them are not building an +editor. + +Every answer is a function from text and an offset to a value: + +```ts +--8<-- "docs/snippets/explanation/idf_language_service.ts:cursor" +``` + +What may be written here, with the state that says why there is nothing: + +```ts +--8<-- "docs/snippets/explanation/idf_language_service.ts:completions" +``` + +An empty list and a missing schema are different results rather than the same +empty array. An editor that renders "no suggestions" identically for a field +that accepts free text and for a schema that failed to load teaches the reader +that the tool is broken in the first case and is silently wrong in the second. + +What this means, and where the name under the cursor is declared: + +```ts +--8<-- "docs/snippets/explanation/idf_language_service.ts:meaning" +``` + +And the findings both the reader and the validator already produce, each with +the characters it concerns: + +```ts +--8<-- "docs/snippets/explanation/idf_language_service.ts:findings" +``` + +Neither the reader nor the validator changed to make that true. They produce +exactly what they produced before, and the regions are correlated onto them +afterwards, which is why the conformance corpus still compares the same +findings it always did. + +## Why an answer is cheap enough for a keystroke + +The grammar allows it. IDF is a flat sequence of statements terminated by a +semicolon, with no nesting, no string literals and no escapes, and a comment +runs from an exclamation mark to the end of its line. So the statement +containing an offset is found by scanning backwards to the nearest semicolon +that is not inside a comment, at a cost proportional to the statement rather +than to the file. + +That is what removes the machinery an editor normally needs. There is no +incremental parser, no cache, and no document to keep in step with the buffer, +because a cursor answer never builds one. The answers are pure functions of +text and an offset, which is also why the same code runs in a browser worker, +in a Node process, and behind an editor server without a conditional import. + +## What it is not + +**Not a protocol.** Nothing in the service imports, depends on, or names a type +from any editor protocol library, and nothing in it ever will. A consumer +translates, and the translation is small: see below. + +**Not a second opinion about the schema.** Offers are the schema's own list for +that field. An explanation is the schema's own prose and the same field facts +the introspection API returns. Reference candidates come from the document the +caller already holds. The service adds a position and a cursor, and nothing +else. + +**Not stateful.** There is no service object to construct, because a service +object is where state would accumulate. Nothing here returns a promise, reads a +file, opens a socket, or consults a clock. + +## Why the Python library has none, permanently + +The answers are byte-offset arithmetic over this grammar, and a second +implementation of that arithmetic is the drift surface the conformance corpus +is least able to police. The corpus compares a finding on its code, its line +and its type name, and never on a column. Two implementations could disagree +about where a value starts for a long time with every gate green, and the first +report would come from a reader whose underline was in the wrong place. + +That is a different kind of boundary from the one on +[browser simulation](browser-simulation.md), where a browser cannot start a +subprocess and the languages are separated by what their runtimes can do. Here +both languages could compute these answers. The decision is that only one of +them should, so that there is one answer to compare against rather than two to +reconcile. + +The parity ledger records the absence as `never`, and `never` is terminal: +moving a capability out of it takes a constitutional amendment rather than an +edit to the ledger. A `not-yet` would have promised a port nobody intends, and +the gate would have demanded a tracked issue for work that is not going to be +done. + +## One implementation, two servers + +The editor extension serves both file kinds without a port. Its existing +server, written in Python, continues to serve Python source. A second server, +written in JavaScript, serves IDF text and wraps this capability. The extension +launches both, and a reader editing a model and a reader editing a script each +get a server that speaks their file. + +So the thing a Python user usually wants from this capability, an editor that +understands the IDF file open in front of them, is not what the absence takes +away. What the absence takes away is calling these answers from Python code. + +## What it costs a reader + +These answers require a JavaScript runtime. `pip install idfkit` alone does not +provide them, and no future version of it will. + +Concretely, what is absent in Python is the region on a finding, the four +cursor answers, and the classification of the text into tokens. What is not +absent is the finding itself: reading and validating report what they have +always reported, with the line number the corpus compares, in both languages. + +## What a consumer still writes + +Translation, debouncing, and rendering. No grammar, no schema tables, and no +position arithmetic. + +That claim is worth more as a file than as a sentence, so the whole of a +language server's translation layer is one worked example. Its conversion from +the service's offsets to the protocol's positions is three lines: + +```ts +--8<-- "docs/snippets/explanation/idf_language_service_translation.ts:range" +``` + +and the rest is lookup tables mapping the service's words onto the protocol's +numbers: + +```ts +--8<-- "docs/snippets/explanation/idf_language_service_translation.ts:tables" +``` + +A completion is then the offer, verbatim, in the protocol's envelope: + +```ts +--8<-- "docs/snippets/explanation/idf_language_service_translation.ts:completion" +``` + +`replaces` comes from the service rather than from this file, and that is not +politeness. An editor's own word rules break on this format in both directions: +type names contain colons, so `BuildingSurface:Detailed` is two words to most +of them, and values contain spaces, so `Office Zone 1` is three. A consumer +working the span out itself would be wrong on most real completions. + +Diagnostics, hovers and go-to-definition are the same shape, and highlighting +is the case that shows what the boundary is for: + +```ts +--8<-- "docs/snippets/explanation/idf_language_service_translation.ts:highlight" +``` + +Nothing there looks for a newline. A field value in this format may be written +across two lines, and no token encoding in use can express a span that crosses +one, so `classify` splits those regions itself and the consumer never learns +that the problem exists. Regions reported for a finding or a declaration stay +whole, because those identify a thing rather than something to draw. + +The file contains no line or column computation of its own, which is the +property the example exists to demonstrate. If a consumer finds itself counting +commas, the service has a gap, and the gap is the service's to close. + +## See also + +- [Capability parity](parity.md), where this entry and its reason are recorded +- [The hazards of a positional format](positional-format-hazards.md), the same + grammar seen from a reader's side rather than an editor's +- [A synchronous core with async edges](sync-core-async-edge.md), which is why + every answer here is a function of text +- [Browser simulation](browser-simulation.md), the other capability that + belongs to one language permanently diff --git a/docs/explanation/naming-map.md b/docs/explanation/naming-map.md index 4a2cf9c..63e4de7 100644 --- a/docs/explanation/naming-map.md +++ b/docs/explanation/naming-map.md @@ -80,12 +80,12 @@ ledger, where permanent single-language capabilities are recorded as such. Generated from -[`governance/naming.toml`](https://github.com/idfkit/idfkit-conformance/blob/governance-2026.9/governance/naming.toml) -at `governance-2026.9`, the governance tag this release pins. It governs `idfkit` and -`@idfkit/core` and `@idfkit/weather`, and it is read at a pinned governance-YYYY.N tag -of idfkit-conformance, never the default branch. Correct the register and regenerate; a -correction made on this page would be overwritten, and it would never reach either -library's naming gate. +[`governance/naming.toml`](https://github.com/idfkit/idfkit-conformance/blob/governance-2026.11/governance/naming.toml) +at `governance-2026.11`, the governance tag this release pins. It governs `idfkit` and +`@idfkit/core` and `@idfkit/weather` and `@idfkit/language`, and it is read at a pinned +governance-YYYY.N tag of idfkit-conformance, never the default branch. Correct the +register and regenerate; a correction made on this page would be overwritten, and it +would never reach either library's naming gate. ## Guessing a name before you look it up @@ -273,7 +273,9 @@ row marked divergent or excluded links to the entry that says why, and a cell re | Concept | Python | TypeScript | Kind | | ------- | ------ | ---------- | ---- | | a parse diagnostic | `idfkit.exceptions.ParseDiagnostic` | `ParseDiagnostic` | aligned | -| diagnostics from a parse | `IDFParseError.diagnostics` | `ParseResult.diagnostics` | [divergent](#diagnostics-from-a-parse) | +| a document and its parse findings | `ParseResult` | `ParseResult` | aligned | +| diagnostics from a parse | `IDFParseError.diagnostics` | `IdfParseError.diagnostics` | aligned | +| recoverable diagnostics from a parse | `ParseResult.diagnostics` | `ParseResult.diagnostics` | aligned | ### Weather stations, files, and geocoding { #map-weather-stations-files-and-geocoding } @@ -529,10 +531,14 @@ row marked divergent or excluded links to the entry that says why, and a cell re | the difference between two schema versions | *absent* | `SchemaDelta` | [divergent](#the-difference-between-two-schema-versions) | | resolve the schema for a detected version | *absent* | `schemaFor` | [divergent](#resolve-the-schema-for-a-detected-version) | -### TypeScript-only structural surfaces { #map-typescript-only-structural-surfaces } +### The writer's controls { #map-the-writers-controls } | Concept | Python | TypeScript | Kind | | ------- | ------ | ---------- | ---- | +| indent width | `indent` | `indent` | aligned | +| comment column | `comment_column` | `commentColumn` | aligned | +| object ordering | `ordering` | `ordering` | aligned | +| pin the version object first | `version_first` | `versionFirst` | aligned | | the options-object types | *absent* | [5 names](#the-options-object-types) | [excluded](#the-options-object-types) | | the static typing surface | *absent* | [4 names](#the-static-typing-surface) | [excluded](#the-static-typing-surface) | | the per-type prototype surface | *absent* | [3 names](#the-per-type-prototype-surface) | [excluded](#the-per-type-prototype-surface) | @@ -547,7 +553,7 @@ row marked divergent or excluded links to the entry that says why, and a cell re | an epJSON document value | *absent* | `EpJson` | [divergent](#an-epjson-document-value) | | serialize one object | *absent* | `writeObject` | [divergent](#serialize-one-object) | | serialize a document to an epJSON value | *absent* | `toEpJson` | [divergent](#serialize-a-document-to-an-epjson-value) | -| read IDF from disk, keeping diagnostics | *absent* | `loadIdfWithDiagnostics` | [divergent](#read-idf-from-disk-keeping-diagnostics) | +| read IDF from disk, keeping diagnostics | `load_idf_with_diagnostics` | `loadIdfWithDiagnostics` | aligned | ### The document's own members { #map-the-documents-own-members } @@ -582,6 +588,43 @@ row marked divergent or excluded links to the entry that says why, and a cell re | migrate a model without blocking | `async_migrate` | *absent* | [divergent](#migrate-a-model-without-blocking) | | create schedule type limits | `create_schedule_type_limits` | `createScheduleTypeLimits` | aligned | +### The language service for IDF text { #map-the-language-service-for-idf-text } + +| Concept | Python | TypeScript | Kind | +| ------- | ------ | ---------- | ---- | +| scan IDF text | *absent* | `scanIdf` | [excluded](#scan-idf-text) | +| classify IDF text | *absent* | `classify` | [excluded](#classify-idf-text) | +| line and column at an offset | *absent* | `lineColumnAt` | [excluded](#line-and-column-at-an-offset) | +| offset at a line and column | *absent* | `offsetAt` | [excluded](#offset-at-a-line-and-column) | +| a source region | *absent* | `Region` | [excluded](#a-source-region) | +| a line and column | *absent* | `LineColumn` | [excluded](#a-line-and-column) | +| a syntax token | *absent* | `Token` | [excluded](#a-syntax-token) | +| a syntax token kind | *absent* | `TokenKind` | [excluded](#a-syntax-token-kind) | +| the syntax layer | *absent* | `SyntaxLayer` | [excluded](#the-syntax-layer) | +| a written statement | *absent* | `Statement` | [excluded](#a-written-statement) | +| schema prose pool | *absent* | `ProsePool` | [excluded](#schema-prose-pool) | +| cursor context | *absent* | `contextAt` | [excluded](#cursor-context) | +| the cursor context record | *absent* | `CursorContext` | [excluded](#the-cursor-context-record) | +| completions at an offset | *absent* | `completionsAt` | [excluded](#completions-at-an-offset) | +| explanation at an offset | *absent* | `explainAt` | [excluded](#explanation-at-an-offset) | +| declaration at an offset | *absent* | `declarationAt` | [excluded](#declaration-at-an-offset) | +| position findings | *absent* | `findingsIn` | [excluded](#position-findings) | +| position findings already in hand | *absent* | `position` | [excluded](#position-findings-already-in-hand) | +| a positioned finding | *absent* | `PositionedFinding` | [excluded](#a-positioned-finding) | +| a completion offer | *absent* | `Offer` | [excluded](#a-completion-offer) | +| an explanation | *absent* | `Explanation` | [excluded](#an-explanation) | +| a declaration site | *absent* | `Declaration` | [excluded](#a-declaration-site) | +| the completion options | *absent* | `CompletionOptions` | [excluded](#the-completion-options) | +| a completion result | *absent* | `CompletionResult` | [excluded](#a-completion-result) | +| an explanation result | *absent* | `ExplanationResult` | [excluded](#an-explanation-result) | +| a declaration result | *absent* | `DeclarationResult` | [excluded](#a-declaration-result) | + +### The column unit { #map-the-column-unit } + +| Concept | Python | TypeScript | Kind | +| ------- | ------ | ---------- | ---- | +| column in a finding | `column` | `column` | [divergent](#column-in-a-finding) | + ## What the notes add Some names carry a note the tables cannot hold. Notes are grouped here, so a note that @@ -795,9 +838,84 @@ Exported by both libraries under the same name. No acronym, so no casing diverge **a parse diagnostic** -One finding from a parse: a message, a location, and a severity. Python reaches it at +One finding from a parse: a message, a machine-readable `code`, and as much location as +the parser had at the point it noticed. Python reaches it at `idfkit.exceptions.ParseDiagnostic` rather than through the top-level `__all__`. +There is no severity. An earlier version of this note claimed one, and neither +implementation has ever carried it: every diagnostic either stopped the parse or was +recoverable, and which of those it was is told by the path it arrives on rather than by +a field. The claim is corrected here rather than implemented, because adding a field to +match a note nobody had checked is the wrong direction. + +`code` was added in feature 002 and is the field the corpus compares. It is derived from +the exception hierarchy by dropping the suffix, so the same eight values exist in both +languages; the message text is not compared and is free to differ. + +TypeScript gained the object concerned in feature 002, and declares a file path and a +column beside it, so both sides name the same kinds of location (FR-033). Only Python +fills all four today: the TypeScript lexer counts lines and not columns, and `parseIdf` +is handed text rather than a path, so neither value exists where a finding is built. +Both are optional and absent rather than invented. `obj_type` against `typeName` stays +as it is: that is the casing rule this register already records, not a gap. + +**a document and its parse findings** + +What the returning path hands back: the document, and the recoverable findings that did +not stop the parse. A frozen dataclass in Python and an interface in TypeScript, which +is the house shape for a value object in each language. + +TypeScript has returned this since it was written. Python's is new in feature 002 and is +reached through `load_idf_with_diagnostics`; the field names match, so a reader moving +between the two reads the same two names in the same order. + +Registered in the same change as the path that returns it, and before either shipped. + +**diagnostics from a parse** + +The findings that stopped a parse, carried by the error that reports it. Both languages +raise by default and both now carry the whole collection rather than one finding +flattened into fields. + +This entry was recorded as divergent, on the grounds that Python raises and TypeScript +returns. That was never what the two did: `parse_idf` defaults to `strict_parsing=True` +and raises, `parseIdf` defaults to `strict: true` and throws. The recorded divergence +described the non-strict mode while reading as though it described the default. What +actually differed was narrower, and feature 002 closed it: TypeScript's error carried +`.line` and `.typeName` from a single finding and now carries the collection, with both +accessors kept resolving to the first finding's values so no existing caller breaks +(FR-014). + +THIS ENTRY IS THE ERROR'S COLLECTION. The result's is `recoverable diagnostics from a +parse`, below. + +The registered pair was `IDFParseError.diagnostics` against `ParseResult.diagnostics` +until 2026-09-04. That crossed the two paths, and the crossing was the shape of the +divergence. Splitting it into one concept per carrier is what makes both entries +alignable, and it costs one TypeScript rename: this concept's TypeScript name moves from +`ParseResult.diagnostics`, which has not gone anywhere and is registered below, to +`IdfParseError.diagnostics`. Python's name does not move, so its budget is untouched. + +The rename is spent deliberately and is the cheaper of the two available. Mapping this +concept to the result instead would have kept the TypeScript name and renamed Python, +which is the same cost against a name that already exists rather than one that is +landing in the same feature. + +Python's logging announcements are unchanged and are not a third name: they are the same +findings reaching a caller who installed a handler. + +**recoverable diagnostics from a parse** + +The findings that did NOT stop the parse, carried alongside the document the parse still +produced. Reached through `load_idf_with_diagnostics` and `loadIdfWithDiagnostics`, or +by parsing with the strict flag off. + +TypeScript has carried these since it was written; this is the name it has always had, +unmoved. Python's is new in feature 002 and is registered here before it ships. +Splitting them from `diagnostics from a parse` above is what lets both concepts be +aligned rather than one entry pretending an error member and a result member are the +same name. + **the station index** Both libraries ship their own index and neither retrieves one to get started (FR-043, @@ -1087,6 +1205,56 @@ Registered before it is written, with `calculate_zone_floor_area` and Registered before it is written, with the other zone measures. +**indent width** + +How far each field line is indented. A count of spaces in Python, the string itself in +TypeScript, which is the shape each language's callers expect and is recorded here +rather than reconciled. + +The DEFAULTS differ and are not moving: two spaces in Python, four in TypeScript. Both +are published and neither is more correct, which is the whole subject of the `two +writers, one model` page. + +**comment column** + +The column field-name comments are padded to. The one writer default of the six that +already agreed: 30 on both sides. + +**object ordering** + +How object types are ordered on the way out. Takes `sorted` or `source` rather than a +boolean, because three behaviours exist across the two languages and two formats and a +flag cannot say which of the three is wanted. + +The defaults differ and are not moving: `sorted` in Python, whose `!-Option SortedOrder` +header announces it, and `source` in TypeScript, which is insertion order. + +**pin the version object first** + +Whether `Version` is written ahead of every other type, whatever the ordering. Defaults +to true on both sides, which is what both writers did before the control existed. + +Composes with `object ordering` rather than overriding it: the ordering decides the +sequence, this decides whether Version is lifted out of it. + +**read IDF from disk, keeping diagnostics** + +Both languages need two loaders because the recoverable findings are worth keeping and +most callers do not want to unwrap a result to get the document. `load_idf` and +`loadIdf` hand back the document and drop the non-fatal findings; these two hand back a +document and its findings together. + +Python had no counterpart until feature 002. The recoverable findings went to the +logging module and were reachable only by installing a handler before the parse, which +is not one call and not the same findings the other language returns. The logging +announcements still fire, unchanged, for callers who rely on them (FR-014); this entry +adds a second way to reach the same findings, and removes none. + +Does the work `strict_parsing=False` does in Python and `strict: false` does in +TypeScript, without asking for it: a strict parse has no recoverable findings, since the +first one stops it, so the Python loader takes no `strict_parsing` argument at all. The +fatal path is unchanged in both and is recorded under `diagnostics from a parse`. + **a document's schema** The schema the document was parsed against. Python's is `EpJSONSchema | None`, because a @@ -1518,23 +1686,6 @@ selects one and passes it as a type argument. Python's stubs apply to every docu with nothing for a caller to select, so there is no value and no type to name, and a Python counterpart would name a choice the Python type checker cannot express. -### Diagnostics from a parse - -| Python | TypeScript | -| ------ | ---------- | -| `IDFParseError.diagnostics` | `ParseResult.diagnostics` | - -Python raises and TypeScript returns. `IDFParseError` carries the diagnostics that -stopped the parse, which is how a Python caller expects to meet a failure it must -handle, and the recoverable findings go to the logging module. TypeScript's `parseIdf` -returns a `ParseResult` whose `diagnostics` array holds the non-fatal findings alongside -the document, because a throwing parser in a browser costs the caller the partial -document it could still show. - -Each is idiomatic where it lives. The difference is visible to a reader, so the parity -ledger records it under `parse-diagnostics` and the corpus asserts what each side -reports for a malformed case. - ### Download a weather file | Python | TypeScript | @@ -2310,21 +2461,6 @@ it would be additive and is not part of this feature. `serialize epJSON to a string` above is the text-producing operation and is aligned on both sides. -### Read IDF from disk, keeping diagnostics - -| Python | TypeScript | -| ------ | ---------- | -| *absent* | `loadIdfWithDiagnostics` | - -TypeScript needs two loaders because it returns rather than raises: `loadIdf` hands back -the document and drops the non-fatal findings, and this one hands back the `ParseResult` -with both. A single loader would either force every caller to unwrap a result they -usually do not want, or throw away findings a viewer wants to show. - -Python needs only `load_idf`, because the findings that stop a parse arrive on -`IDFParseError` and the recoverable ones go to the logging module. That is the -`diagnostics from a parse` divergence, seen from the disk-reading side. - ### The path a document was read from | Python | TypeScript | @@ -2531,6 +2667,28 @@ is the input and output divergence, seen from the migration side. `migrate a model to a newer version` above is the operation. Its note already names this pair; this entry gives the second name a concept of its own. +### Column in a finding + +| Python | TypeScript | +| ------ | ---------- | +| `column` | `column` | + +Python string indices are code points; JavaScript string indices are UTF-16 code units. +The two agree for every character below the astral planes and differ for text containing +anything above them, which in practice means an emoji in a comment. + +Each is correct in its own ecosystem, and converting either would make positions wrong +for that language's own consumers: the Language Server Protocol's default position +encoding is UTF-16, as are Monaco's columns and CodeMirror's offsets, so the JavaScript +value is what every JavaScript consumer needs unconverted. + +Harmless in practice today: the corpus compares findings on (code, line, typeName) and +never on a column. Registered because an unregistered divergence is indistinguishable +from drift. + +Canonical form across the boundary: **1-based, counted in the host language's own string +index unit**. + ## The canonical form across the boundary A divergence in a name costs you a lookup. A divergence in a value costs you a bug, @@ -2556,6 +2714,9 @@ model. | detect a document version | `get_idf_version` | `getIdfVersion` | string | | detect an epJSON document version | `idfkit.epjson_parser.get_epjson_version` | `getEpJsonVersion` | string | | [render a version as text](#render-a-version-as-text) | `version_string` | *absent* | string | +| [a source region](#a-source-region) | *absent* | `Region` | half-open, offsets into the source text, an empty region where start equals end | +| [a line and column](#a-line-and-column) | *absent* | `LineColumn` | both counts 1-based; the column in the host language's own string index unit | +| [column in a finding](#column-in-a-finding) | `column` | `column` | 1-based, counted in the host language's own string index unit | The rule to carry away: keep each language's idiomatic shape in memory, and move the canonical form across the boundary. Anything written to a file, sent in a message, or @@ -3051,6 +3212,332 @@ resolved by the change that withdraws or renames each name, as the `intersect an surfaces` and `the vector image surface` entries already say for their own duplicates. A withdrawal counts as a rename, so each of these has one budget to spend and no more. +### Scan IDF text + +**Python**: none, and never. + +**TypeScript**: `scanIdf`. + +Second-language-only by decision, recorded on the parity ledger as +`idf-language-service`. Listed here so that a Python counterpart is never added without +the ledger's `never` being amended first. + +The one entry point to the syntax layer. Takes text and nothing else, and never throws. + +### Classify IDF text + +**Python**: none, and never. + +**TypeScript**: `classify`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". The complete-coverage view over a scanned +layer, yielding trivia as the complement of the stored tokens rather than storing it. +Listed here so that a Python counterpart is never added without the ledger's `never` +being amended first. + +### Line and column at an offset + +**Python**: none, and never. + +**TypeScript**: `lineColumnAt`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". Turns an offset into the 1-based line and +column an editor draws with. Listed here so that a Python counterpart is never added +without the ledger's `never` being amended first. + +### Offset at a line and column + +**Python**: none, and never. + +**TypeScript**: `offsetAt`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". The inverse of `lineColumnAt`, for a consumer +whose editor speaks in line and column and whose service speaks in offsets. Listed here +so that a Python counterpart is never added without the ledger's `never` being amended +first. + +### A source region + +**Python**: none, and never. + +**TypeScript**: `Region`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". A half-open span of the source text, and the +unit of every position this capability reports. Listed here so that a Python counterpart +is never added without the ledger's `never` being amended first. + +### A line and column + +**Python**: none, and never. + +**TypeScript**: `LineColumn`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". Derived from a region rather than stored +beside one, because storing it would double the size of every region to hold two numbers +that are a function of one. Listed here so that a Python counterpart is never added +without the ledger's `never` being amended first. + +The unit divergence itself is registered separately, under `column in a finding`. + +### A syntax token + +**Python**: none, and never. + +**TypeScript**: `Token`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". One meaningful span of text with a +grammatical kind, materialised on demand from the layer's packed arrays rather than +stored as an object. Listed here so that a Python counterpart is never added without the +ledger's `never` being amended first. + +### A syntax token kind + +**Python**: none, and never. + +**TypeScript**: `TokenKind`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". The grammatical kinds a token can carry, +`trivia` included even though trivia is never stored. Listed here so that a Python +counterpart is never added without the ledger's `never` being amended first. + +### The syntax layer + +**Python**: none, and never. + +**TypeScript**: `SyntaxLayer`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". The whole scan: the text it was built from, +its statements, and its packed token store. Listed here so that a Python counterpart is +never added without the ledger's `never` being amended first. + +Python has a formatting-preserving concrete syntax tree already, under +`lossless-round-trip`, and this is not it. That tree exists to be written back out; this +layer exists to be positioned against, holds no schema meaning, and is built only when a +caller names `scanIdf`. + +### A written statement + +**Python**: none, and never. + +**TypeScript**: `Statement`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". One semicolon-terminated statement as it +appears in the text, carrying regions and no schema meaning: it is not an object in the +model, and a statement with the wrong number of fields is still a statement. Listed here +so that a Python counterpart is never added without the ledger's `never` being amended +first. + +### Schema prose pool + +**Python**: none, and never. + +**TypeScript**: `ProsePool`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". The deduplicated prose the schema bundle +carries, which `describeObjectType` already takes as an optional argument. It becomes a +public name here because `explainAt` cannot name its own optional prose parameter +without it. + +Python has no counterpart because it needs none: its schema access reads prose directly +rather than through a pool, so there is nothing for a Python name to refer to. Listed +here so that a Python counterpart is never added without the ledger's `never` being +amended first. + +Declared today at `packages/core/src/introspect/describe.ts` and not re-exported from +that package's root. Registering it is what lets the change that exports it pass the +naming gate. + +### Cursor context + +**Python**: none, and never. + +**TypeScript**: `contextAt`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". Which statement an offset falls in, which +field, and which part, computed by a bounded backward scan rather than by building a +layer. Listed here so that a Python counterpart is never added without the ledger's +`never` being amended first. + +### The cursor context record + +**Python**: none, and never. + +**TypeScript**: `CursorContext`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". What `contextAt` returns. A concept of its +own because the operation and its result are two public names, and FR-005 forbids one +concept carrying both. Listed here so that a Python counterpart is never added without +the ledger's `never` being amended first. + +### Completions at an offset + +**Python**: none, and never. + +**TypeScript**: `completionsAt`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". What the schema permits at an offset, taken +from the schema itself and never from a second table. Listed here so that a Python +counterpart is never added without the ledger's `never` being amended first. + +### Explanation at an offset + +**Python**: none, and never. + +**TypeScript**: `explainAt`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". The schema's own facts about whatever is +under an offset, reported and never paraphrased. Listed here so that a Python +counterpart is never added without the ledger's `never` being amended first. + +### Declaration at an offset + +**Python**: none, and never. + +**TypeScript**: `declarationAt`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". Where the name under an offset is declared, +or nothing when it is declared nowhere, which is the dangling-reference finding's answer +to give rather than this one's. Listed here so that a Python counterpart is never added +without the ledger's `never` being amended first. + +### Position findings + +**Python**: none, and never. + +**TypeScript**: `findingsIn`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". Existing parse and validation findings with a +region attached. It adds no findings of its own and holds no second opinion about the +schema, which is what keeps this out of Python's way rather than duplicating it. Listed +here so that a Python counterpart is never added without the ledger's `never` being +amended first. + +### Position findings already in hand + +**Python**: none, and never. + +**TypeScript**: `position`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". The separately exposed half of `findingsIn`, +for a consumer that already holds findings from its own run and wants regions attached +without paying for a second parse. It is a distinct public name rather than an option on +`findingsIn` because the two take different inputs: one takes text and reads it, the +other takes findings and a layer the caller already built. Listed here so that a Python +counterpart is never added without the ledger's `never` being amended first. + +### A positioned finding + +**Python**: none, and never. + +**TypeScript**: `PositionedFinding`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". An existing finding with a region and a +precision attached, generic over the finding type so that a parse diagnostic and a +validation error travel one path without either being modified. Listed here so that a +Python counterpart is never added without the ledger's `never` being amended first. + +### A completion offer + +**Python**: none, and never. + +**TypeScript**: `Offer`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". One completion, carrying the region it would +replace so that a consumer renders and applies a list without measuring anything itself. +Listed here so that a Python counterpart is never added without the ledger's `never` +being amended first. + +### An explanation + +**Python**: none, and never. + +**TypeScript**: `Explanation`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". The region described, the schema's field +facts, its prose when the caller supplied a pool, and the manual location. Listed here +so that a Python counterpart is never added without the ledger's `never` being amended +first. + +Carries `FieldDescription` and `DocsUrl` unchanged; both are already registered above. + +### A declaration site + +**Python**: none, and never. + +**TypeScript**: `Declaration`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". Where a name is declared: the region of the +declaring statement's name field, and that statement's canonical type. Listed here so +that a Python counterpart is never added without the ledger's `never` being amended +first. + +### The completion options + +**Python**: none, and never. + +**TypeScript**: `CompletionOptions`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". The optional document and prose pool +`completionsAt` takes, an options-object rather than two more positional arguments. +Listed here so that a Python counterpart is never added without the ledger's `never` +being amended first. + +### A completion result + +**Python**: none, and never. + +**TypeScript**: `CompletionResult`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". A discriminated union rather than an array, +so that "the schema constrains nothing here" and "I could not consult a schema" are +different answers instead of the same empty list. Listed here so that a Python +counterpart is never added without the ledger's `never` being amended first. + +### An explanation result + +**Python**: none, and never. + +**TypeScript**: `ExplanationResult`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". The same discriminated shape as +`CompletionResult`, for `explainAt`. Listed here so that a Python counterpart is never +added without the ledger's `never` being amended first. + +### A declaration result + +**Python**: none, and never. + +**TypeScript**: `DeclarationResult`. + +Part of `idf-language-service`, second-language-only by decision and recorded on the +parity ledger with absence_kind = "never". The same discriminated shape again, for +`declarationAt`. Listed here so that a Python counterpart is never added without the +ledger's `never` being amended first. + ## Names that have spent their rename Every name gets one rename during the unification. One. A name that has spent it is @@ -3066,6 +3553,7 @@ library is unstable. | [the document class](#the-document-class) | `IdfDocument` | TypeScript | 1 | | [untyped collection access](#untyped-collection-access) | *withdrawn* | TypeScript | 1 | | [generated object types](#generated-object-types) | `@idfkit/types-v26-1` | TypeScript | 1 | +| diagnostics from a parse | `IdfParseError.diagnostics` | TypeScript | 1 | | detect a document version | `getIdfVersion` | TypeScript | 1 | | detect an epJSON document version | `getEpJsonVersion` | TypeScript | 1 | diff --git a/docs/explanation/parity.md b/docs/explanation/parity.md index 0f2e436..29dd3bd 100644 --- a/docs/explanation/parity.md +++ b/docs/explanation/parity.md @@ -79,36 +79,36 @@ Ids get added and deprecated. They do not get renamed. Generated from -[`governance/parity.toml`](https://github.com/idfkit/idfkit-conformance/blob/governance-2026.9/governance/parity.toml) -at `governance-2026.9`, the governance tag this release pins. Correct the ledger and +[`governance/parity.toml`](https://github.com/idfkit/idfkit-conformance/blob/governance-2026.11/governance/parity.toml) +at `governance-2026.11`, the governance tag this release pins. Correct the ledger and regenerate; a correction made on this page would be overwritten, and it would never reach either library's CI gate. ## Every capability at a glance { #at-a-glance } -33 capabilities, counted by availability and then listed in full. Follow a capability to +34 capabilities, counted by availability and then listed in full. Follow a capability to read what differs where the two libraries differ, and whether an absence is temporary or permanent. | Availability | Python | JavaScript | | ------------ | ------ | ---------- | -| complete | 28 | 12 | -| partial | 3 | 4 | +| complete | 30 | 15 | +| partial | 1 | 2 | | absent, not yet | 0 | 13 | -| absent, never | 2 | 4 | +| absent, never | 3 | 4 | | Capability | Tier | Python | JavaScript | | ---------- | ---- | ------ | ---------- | | [Reading IDF and epJSON](#parse) | 1 | complete | complete | -| [Writing IDF and epJSON](#write) | 1 | partial | partial | +| [Writing IDF and epJSON](#write) | 1 | complete | complete | | [Documents, collections, and objects](#document-model) | 1 | complete | complete | | [Reference graph](#references) | 1 | complete | complete | | [Schema access and the version registry](#schema-access) | 1 | complete | complete | | [Model validation](#validation) | 1 | complete | complete | -| [Describing an object type from the schema](#introspection) | 1 | complete | partial | +| [Describing an object type from the schema](#introspection) | 1 | complete | complete | | [Building EnergyPlus documentation URLs](#documentation-urls) | 1 | complete | complete | | [Static types generated from the schema](#generated-object-types) | 1 | partial | complete | -| [Diagnostics from a parse](#parse-diagnostics) | 1 | partial | complete | +| [Diagnostics from an IDF parse](#parse-diagnostics) | 1 | complete | complete | | [The weather station index](#weather-index) | 1 | complete | partial | | [Retrieving weather and design-day files](#weather-download) | 1 | complete | partial | | [Declaring the conformance level a release passes](#conformance-declaration) | 1 | complete | complete | @@ -132,6 +132,7 @@ permanent. | [Rendering a three-dimensional scene](#scene-rendering) | permanent | absent (never) | complete | | [eppy compatibility surface](#eppy-compatibility) | permanent | complete | absent (never) | | [Caching retrieved weather files on disk](#weather-file-cache) | permanent | complete | absent (never) | +| [Language service for IDF text](#idf-language-service) | permanent | absent (never) | complete | ## Tier 1: the shared core { #tier-1 } @@ -157,55 +158,7 @@ possibility. ### Writing IDF and epJSON { #write } -**Python** partial · **JavaScript** partial · Tier 1 · ledger id `write` - -!!! info "What differs, and why" - - Both libraries write both formats, and every document either one writes is read back by the other - and by EnergyPlus. What they do NOT do is produce the same bytes. One model written from Python and - from JavaScript gives two files whose object headers match and whose every field line differs, and - neither is more correct than the other, so the difference is recorded here rather than resolved by - changing a writer both ecosystems have already published. - - Measured, not asserted. `5ZoneAirCooled.idf` from EnergyPlus 26.1.0, 359 objects, through - `load_idf`/`save_idf` and through `loadIdf`/`saveIdf`, differs in seven ways: - - 1. Python opens the file with `!-Generator idfkit v` and `!-Option SortedOrder`. - TypeScript writes no header. - 2. Python orders objects by type name, alphabetically, with Version pinned first, which is what - its `SortedOrder` header declares. TypeScript groups objects by type in the order the types - first appeared in the source, with Version pinned first. - 3. Python indents field lines two spaces. TypeScript indents four, and takes an `indent` option. - 4. Both align the `!-` comment at column 30. They therefore overflow at different values, and on - overflow Python writes the comment flush against the comma while TypeScript keeps one space. - 5. Python renders a float with `%g`, so an integral value loses its decimal point: `30.` in the - source comes back as `30`, and `0.0` as `0`. TypeScript consults the schema and writes `30.0` - and `0.0`, because JavaScript has one number type and a real-valued field would otherwise be - indistinguishable from an integer one on the way out. - 6. Python title-cases every word of the field-name comment, `!- Number Of Vertices` and - `!- View Factor To Ground`. TypeScript keeps a list of minor words lowercase so the comment - reads as EnergyPlus writes it, `!- Number of Vertices` and `!- View Factor to Ground`. Both - drop the unit suffix the source carries, `{deg}` and the rest. - 7. Python numbers the comment on each repeat of an extensible group, `!- Vertex X Coordinate 2`. - TypeScript repeats the unnumbered name on every repeat. - - Blank lines differ with them: Python puts one between every pair of objects, TypeScript one between - objects of the same type and two at each type boundary. On this file that is 4031 lines against - 4125, for the same 359 objects. - - `partial` on BOTH sides, because neither writer's controls contain the other's. Python's `write_idf` - takes `output_type`, so `"nocomment"` and `"compressed"`, and `preserve_formatting`; it offers no way - to set the indent, the comment column, or the ordering. TypeScript's `writeIdf` takes `comments`, - `commentColumn`, `indent`, and `versionFirst`; it has no compressed mode and no lossless mode, the - second of which is [`lossless-round-trip`](#lossless-round-trip) rather than part of this entry. - - What is proven, and by what. The corpus asserts that a document survives its OWN writer without - structural loss: assertion 3 re-parses each library's IDF output and compares object types, names, - field names, field values, and field order against the document it started from. It does not compare - the text, and `runners/compare.md` forbids any comparator from ever doing so, because one JSON value - has many JSON texts. So the seven differences above are outside every assertion the corpus runs, and - recording them here is the only place a reader meets them. A reader who needs byte-identical output - from both languages does not have it and is not going to: pass EnergyPlus the model, not a diff. +**Python** complete · **JavaScript** complete · Tier 1 · ledger id `write` ??? note "Vocabulary this capability owns in the naming register" @@ -276,24 +229,7 @@ possibility. ### Describing an object type from the schema { #introspection } -**Python** complete · **JavaScript** partial · Tier 1 · ledger id `introspection` - -!!! info "What differs, and why" - - TypeScript never populates `memo` or `note`. Both are members of the two types, so the field set - matches, but they are always undefined. Python fills `memo` for 845 of 858 object types and `note` - for 6,212 of 12,712 fields in 26.1.0, both from epJSON keys that `@idfkit/schemas` drops on purpose - to keep the bundle off the parse critical path. The `@idfkit/schemas/docs` subpath its own header - comment promises does not exist. Describing a type is what a REPL, a notebook, and an LSP hover are - for, so the prose is most of the value; this is partial rather than complete. - - Two further differences, both small and both pinned by tests. `enumValues` omits the empty string - that Python includes for 1,378 of its 2,293 enum-bearing fields, and omits the sentinel lists - (`Autosize`, `Autocalculate`) Python recovers from an anyOf branch for 769 fields. Field ORDER - differs for exactly two types in 26.1.0, `ZoneProperty:UserViewFactors:BySurfaceName` and - `ZoneTerminalUnitList`, and for six more in 8.9.0 through 9.2.0, because the bundle sorts property - keys for content-addressing and those types carry no positional field list to restore declaration - order from. +**Python** complete · **JavaScript** complete · Tier 1 · ledger id `introspection` ??? note "Vocabulary this capability owns in the naming register" @@ -335,26 +271,16 @@ possibility. - generated object types - a version type map -### Diagnostics from a parse { #parse-diagnostics } - -**Python** partial · **JavaScript** complete · Tier 1 · ledger id `parse-diagnostics` - -!!! info "What differs, and why" - - Both libraries produce diagnostics for a malformed input; only one hands them back. Python raises - IDFParseError carrying the diagnostics that stopped the parse, and reports the recoverable ones - (skipped malformed objects, discarded formatting trees, surplus fields on a non-extensible type) - through the logging module, where a caller who wants them must install a handler. TypeScript - returns them: parseIdf and loadIdfWithDiagnostics both yield a ParseResult whose `diagnostics` - array holds the non-fatal findings alongside the document. +### Diagnostics from an IDF parse { #parse-diagnostics } - This matters to the conformance corpus, whose `diagnostics` assertion compares what each side - reports for a malformed case (contracts/conformance-corpus.md). +**Python** complete · **JavaScript** complete · Tier 1 · ledger id `parse-diagnostics` ??? note "Vocabulary this capability owns in the naming register" - a parse diagnostic - diagnostics from a parse + - recoverable diagnostics from a parse + - a document and its parse findings ### The weather station index { #weather-index } @@ -807,4 +733,51 @@ two different mechanisms serving two different runtimes. - the weather file cache +### Language service for IDF text { #idf-language-service } + +**Python** absent (never) · **JavaScript** complete · Permanently single-language · ledger id `idf-language-service` + +!!! abstract "JavaScript only, permanently" + + Deliberately second-language-only. The answers are computed from byte offsets into the source text, + and a second implementation of that arithmetic is the drift surface the corpus is least able to + police: it compares findings on (code, line, typeName) and never on a column, so two implementations + could disagree about a position for a long time without any gate noticing. + + The editor extension serves both file kinds without a port. Its existing server, written in Python, + continues to serve Python source; a second server written in JavaScript serves IDF text and wraps + this capability. One implementation, two servers. + + What this costs a reader: getting these answers requires a JavaScript runtime. `pip install idfkit` + alone does not provide them. + +??? note "Vocabulary this capability owns in the naming register" + + - scan IDF text + - classify IDF text + - line and column at an offset + - offset at a line and column + - a source region + - a line and column + - a syntax token + - a syntax token kind + - the syntax layer + - a written statement + - schema prose pool + - cursor context + - completions at an offset + - explanation at an offset + - declaration at an offset + - position findings + - position findings already in hand + - the cursor context record + - a positioned finding + - a completion offer + - an explanation + - a declaration site + - the completion options + - a completion result + - an explanation result + - a declaration result + diff --git a/docs/snippets/explanation/idf_language_service.ts b/docs/snippets/explanation/idf_language_service.ts new file mode 100644 index 0000000..77b0404 --- /dev/null +++ b/docs/snippets/explanation/idf_language_service.ts @@ -0,0 +1,57 @@ +// Preamble, not shown on the page: the values this example assumes it already +// has, each with the type the page's earlier steps would have given it. +import { lineColumnAt, scanIdf } from '@idfkit/core'; +import type { IdfDocument, ProsePool, Schema } from '@idfkit/core'; +import { completionsAt, contextAt, declarationAt, explainAt, findingsIn } from '@idfkit/language'; +declare const text: string; +declare const schema: Schema; +declare const model: IdfDocument; +declare const prose: ProsePool; +declare const offset: number; + +// --8<-- [start:cursor] +const context = contextAt(text, offset, schema); +context.at; // 'typeName' | 'field' | 'comment' | 'betweenStatements' +context.typeName; // 'BuildingSurface:Detailed', when the schema defines the written type +context.fieldIndex; // which field, counted the way the schema counts them +// --8<-- [end:cursor] + +// --8<-- [start:completions] +const completions = completionsAt(text, offset, schema, { document: model, prose }); +if (completions.status === 'ok') { + for (const offer of completions.offers) { + offer.value; // the text to insert + offer.replaces; // the characters it stands in for + offer.required; // whether the schema marks the field required + } +} +// 'unconstrained', 'noSchema', 'unknownType' and 'notApplicable' are the other +// four states, and each is a different thing to tell the reader. +// --8<-- [end:completions] + +// --8<-- [start:meaning] +const meaning = explainAt(text, offset, schema, prose); +if (meaning.status === 'ok') { + meaning.explanation.prose; // the schema's own words, or undefined where it carries none + meaning.explanation.field; // type, units, range, default, permitted values + meaning.explanation.docs; // where the EnergyPlus manual documents it + meaning.explanation.region; // the characters to highlight while it is shown +} + +const declaration = declarationAt(text, offset, schema, model); +if (declaration.status === 'ok') { + for (const declared of declaration.declarations) { + declared.region; // where the name under the cursor is declared + declared.typeName; // the type that declares it + } +} +// --8<-- [end:meaning] + +// --8<-- [start:findings] +const layer = scanIdf(text); +for (const finding of findingsIn(text, schema)) { + const { line, column } = lineColumnAt(layer, finding.region.start); + finding.precision; // 'field' when the region is the offending value itself + console.log(`${line}:${column} ${finding.message}`); +} +// --8<-- [end:findings] diff --git a/docs/snippets/explanation/idf_language_service_translation.ts b/docs/snippets/explanation/idf_language_service_translation.ts new file mode 100644 index 0000000..ebe978d --- /dev/null +++ b/docs/snippets/explanation/idf_language_service_translation.ts @@ -0,0 +1,180 @@ +// Preamble, not shown on the page: the protocol's own shapes, and the two +// things an editor server already holds for the buffer it is serving. +// +// A real server imports the shapes from `vscode-languageserver` and gets the +// buffer from `vscode-languageserver-textdocument`. They are written out here +// so that this file depends on nothing but the service it translates, which is +// also the claim being made: the service names no protocol type, and wiring it +// into one is this file and nothing more. +import { classify, scanIdf, Severity } from '@idfkit/core'; +import type { + IdfDocument, + ParseDiagnostic, + ProsePool, + Region, + Schema, + TokenKind, + ValidationError, +} from '@idfkit/core'; +import { completionsAt, declarationAt, explainAt, findingsIn } from '@idfkit/language'; +import type { Offer, PositionedFinding } from '@idfkit/language'; + +interface Position { + readonly line: number; + readonly character: number; +} +interface Range { + readonly start: Position; + readonly end: Position; +} +interface TextEdit { + readonly range: Range; + readonly newText: string; +} +interface CompletionItem { + readonly label: string; + readonly kind: number; + readonly detail: string | undefined; + readonly documentation: string | undefined; + readonly textEdit: TextEdit; +} +interface Hover { + readonly contents: { readonly kind: 'markdown'; readonly value: string }; + readonly range: Range; +} +interface Location { + readonly uri: string; + readonly range: Range; +} +interface Diagnostic { + readonly range: Range; + readonly severity: number; + readonly code: string | undefined; + readonly message: string; + readonly source: string; +} + +/** + * The open buffer, as the protocol library models it: its text, its URI, and + * its own conversion from an offset to a protocol position. Every position in + * this file comes out of `positionAt`, which is why none is computed here. + */ +declare const buffer: { + getText(): string; + positionAt(offset: number): Position; + readonly uri: string; +}; + +/** The protocol library's token builder, which does the wire encoding. */ +declare const tokens: { push(range: Range, type: string): void }; + +declare const schema: Schema; +declare const model: IdfDocument; +declare const prose: ProsePool; + +// --8<-- [start:range] +const toRange = (region: Region): Range => ({ + start: buffer.positionAt(region.start), + end: buffer.positionAt(region.end), +}); +// --8<-- [end:range] + +// --8<-- [start:tables] +/** The protocol's `CompletionItemKind` numbers, for the service's three kinds. */ +const ITEM_KIND: Readonly> = { + objectType: 7, // Class + enumValue: 12, // Value + referenceTarget: 18, // Reference +}; + +/** The protocol's `DiagnosticSeverity` numbers, for the three the corpus compares. */ +const SEVERITY: Readonly> = { + error: 1, + warning: 2, + info: 3, +}; + +/** Semantic token types, for the token kinds worth colouring. */ +const TOKEN_TYPE: Readonly>> = { + typeName: 'class', + value: 'string', + comment: 'comment', +}; +// --8<-- [end:tables] + +// --8<-- [start:completion] +function onCompletion(offset: number): CompletionItem[] { + const result = completionsAt(buffer.getText(), offset, schema, { document: model, prose }); + if (result.status !== 'ok') return []; + return result.offers.map((offer) => ({ + label: offer.value, + kind: ITEM_KIND[offer.kind], + detail: offer.required === true ? 'required' : undefined, + documentation: offer.prose, + // `replaces` is the service's answer, not this file's guess. + textEdit: { range: toRange(offer.replaces), newText: offer.value }, + })); +} +// --8<-- [end:completion] + +// --8<-- [start:hover] +function onHover(offset: number): Hover | null { + const result = explainAt(buffer.getText(), offset, schema, prose); + if (result.status !== 'ok') return null; + const { explanation } = result; + const parts = [ + `**${explanation.fieldName ?? explanation.typeName}**`, + explanation.prose, + explanation.field?.units === undefined ? undefined : `Units: ${explanation.field.units}`, + explanation.docs === undefined + ? undefined + : `[${explanation.docs.label}](${explanation.docs.url})`, + ]; + return { + contents: { kind: 'markdown', value: parts.filter((part) => part !== undefined).join('\n\n') }, + range: toRange(explanation.region), + }; +} +// --8<-- [end:hover] + +// --8<-- [start:definition] +function onDefinition(offset: number): Location[] { + const result = declarationAt(buffer.getText(), offset, schema, model); + if (result.status !== 'ok') return []; + return result.declarations.map((declared) => ({ + uri: buffer.uri, + range: toRange(declared.region), + })); +} +// --8<-- [end:definition] + +// --8<-- [start:diagnostics] +/** A reading finding carries no severity, because a file that will not read is an error. */ +const severityOf = (finding: PositionedFinding): Severity => + 'severity' in finding ? finding.severity : Severity.ERROR; + +function onDiagnostics(): Diagnostic[] { + return findingsIn(buffer.getText(), schema).map((finding) => ({ + range: toRange(finding.region), + severity: SEVERITY[severityOf(finding)], + code: finding.code, + message: finding.message, + source: 'idfkit', + })); +} +// --8<-- [end:diagnostics] + +// --8<-- [start:highlight] +function onSemanticTokens(): void { + for (const token of classify(scanIdf(buffer.getText()))) { + const type = TOKEN_TYPE[token.kind]; + // `classify` has already split every region that crossed a newline, and it + // covers the gaps between tokens too, so this loop neither looks for a + // line boundary nor works out what it skipped. + if (type !== undefined) tokens.push(toRange(token), type); + } +} +// --8<-- [end:highlight] + +// Not shown on the page: what the server registers these four as. +export { onCompletion, onDefinition, onDiagnostics, onHover, onSemanticTokens }; diff --git a/mkdocs.yml b/mkdocs.yml index 49fca0d..bd284e2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -169,6 +169,7 @@ nav: - Content-addressed schemas: explanation/content-addressed-schemas.md - A synchronous core with async edges: explanation/sync-core-async-edge.md - Browser simulation: explanation/browser-simulation.md + - A language service for IDF text: explanation/idf-language-service.md - What is not finished: explanation/what-is-not-finished.md - Simulation architecture: concepts/simulation-architecture.md - Weather data pipeline: concepts/weather-pipeline.md diff --git a/pyproject.toml b/pyproject.toml index 8ab5f3c..01feeb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ level = "conformance-2026.8" # idfkit-conformance. Read by docs/hooks/parity_macro.py, scripts/render_parity_page.py and # scripts/render_naming_map.py through the duplicated scripts/_governance_source.py. [tool.idfkit.governance] -level = "governance-2026.10" +level = "governance-2026.11" # Documentation artifact level the TypeScript half of the site renders from, as an immutable tag # in idfkit-js. It carries the TypeScript examples the pages include and the TypeDoc JSON the