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