feat: publish the Hime-san Beta 2 LSP

Add the standard-library language server, editor-neutral protocol contract, additive version feature discovery, and bounded security regressions while leaving the Sando runtime unchanged. Material design and implementation assistance was provided by OpenAI Codex.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
2026-08-12 20:31:07 -04:00
parent e3a94fd16b
commit f091cd67f6
25 changed files with 2463 additions and 89 deletions
+25
View File
@@ -6,6 +6,31 @@ Sandwich Hime follows semantic versioning after final v1. Compiler and nested
runtime releases are versioned independently and listed together when they form
one coordinated release.
## v1.0.0-beta.2 — 2026-08-12
Compiler-only release; the unchanged Apache runtime remains
`sando/v1.0.0-beta.1` with ABI `sando.v1`.
### Added
- Standard-library-only `himesan lsp --stdio` with one workspace per process,
full-document overlays, 200 ms edit debounce, cancellation, and bounded
indexing through the compiler's existing filesystem boundaries.
- Live diagnostics, trust warnings, duplicate/cycle reporting, UTF-16 LSP
positions, tag/context/component hover, document symbols, typed component
completion, and component go-to-definition.
- Additive `features: ["lsp-stdio"]` in `himesan version --json`.
- Protocol framing and malformed-input fuzzing; overlay, Unicode, CRLF, NUL,
deletion, nested-module, symlink, completion, definition, cancellation,
shutdown, no-write, and resource-limit tests.
### Boundaries
The server does not generate, run Go or project code, fetch dependencies,
access the network, start the dev supervisor, format, rename, add imports, or
delegate general Go completion to `gopls`. Generated-file freshness remains an
explicit `himesan check --json` workflow.
## v1.0.0-beta.1 — 2026-08-12
This is the first installable public beta: `sando/v1.0.0-beta.1` for the
+1 -1
View File
@@ -1 +1 @@
{"schema_version":2,"project":"sandwich-hime","export_policy":"exact-allowlist-v1","export_mode":"release","file_count":80,"allowlist_sha256":"e40a56b3431efdd99b3a78c38c387722de347216640383fc849569a817edf5c6","manifest_sha256":"fd410436e310825eb908ac3795bb1bef861e8e6b0a95183d8692c2a0d688e141"}
{"schema_version":2,"project":"sandwich-hime","export_policy":"exact-allowlist-v1","export_mode":"release","file_count":90,"allowlist_sha256":"393ee598dc7e12cdbb603887bf06599e46b40d7c19e4ff693a818cc32afb01ec","manifest_sha256":"655bfadbddf5967288147f5b1762debd3f4b106d86c044706c41cf6aabca5e00"}
+23 -13
View File
@@ -2,7 +2,7 @@
658ba4b4645426f8c3249337f47669074ae9249a31703dcd9ea4c1afec45e20b ./.gitattributes
d5ae411fb422b2388cac220f9655900eecbc49ece961b2bb2a6610347733b756 ./.gitignore
98f663ab0f376b4550094465ec2e06192d1e0b0707604ec6794f20b0d10952c1 ./AI_CONTRIBUTIONS.md
b1faa7df0336b9ba68b28136fb72e9ba44185c35ba8cbec01d01c00599ea5c46 ./CHANGELOG.md
0828545d3aa440e1ec5dce4b934de6800413f55a925988b60923c5ee9b700a4e ./CHANGELOG.md
b696cab3cf482ff5737501371cca749369b119351383e698ced42bcdbcbfc8ae ./CLA.md
797e884105738fc931b585b695424f43ec5f296d8ab9bba5191b096e87a9e2c2 ./CONTRIBUTING.md
86d7e49d5d90e0f98a4ad0f14b5d8b9f11ed09a1e29ecdf27388316b28e195e8 ./COPYRIGHT
@@ -13,35 +13,38 @@ a4570d054f072d33b8f17b0c8b162a6ee0ca37d7df2b1aee7e4b728ab350a892 ./GOVERNANCE.m
47d857e49f89596bac9b09fc8ca57a668a33d01e2b51508acfc92ed321cdc27f ./LICENSES.md
b6aa08e5ccaec3c5dccdc19d7cd7f54a70adae4d57966263c7aa353c7ba70e08 ./MAINTAINERS.md
6638db2f1fba831c79de835ce95c847a5b36c5b5c693b99a28655b2d096cc440 ./OUTPUT_EXCEPTION.md
043604db73a1a5ae603f455114eadb3c6d67d8426270ba697fd72d3204000443 ./README.md
9b78254033063ec97f1c9f66aaef9503e477e1f5c3dd602bb78fd78f9e64b90b ./RELEASE.md
c3ac873ae2e6248e1d86dd542a11557b24b8dba80e4785f3bc1018152020235c ./README.md
2751674c180f15a42c1d2b40cf149be4138aa6cf247d7be176f1f0c468103c24 ./RELEASE.md
209decb6769646eb2f58e312fbcd9c497c26234f3d3115bae3f20493b8178584 ./ROADMAP.md
ea26e6bcdf97746627f21ba64bed16bdb7630f808215e19558ff7c7550422491 ./SECURITY.md
0fef473ac46b71215d1eb7922da4594210ffbbb8bb2dedd5e531fb3bd09396e1 ./SECURITY.md
53bd6eda804d6b782bdb07115ec197c890813cf2d5d0125dfe8f47f5f92f75b0 ./SPEC.md
3d9e680cdfe147df7cc9ff29ecf1d3e566e9cd559ae84db4880e559b9c7c7205 ./TRADEMARKS.md
136a6d82db842547b342f8b0c9ffdc7c04f7c9b473b4ef6dca9dbc940cb24b54 ./cmd/himesan/main.go
3011a435aaeb572d34e2d2b582865fd63268c92a9ae3f0ae1a56d1a59d83cd43 ./cmd/himesan/main_test.go
8cd8db68e1300f9b78cc7235855853cbc7aeb499a921419e23a22e4d22826fcb ./cmd/himesan/main.go
0709990e28d58d50e49219690e435338b269284efb6c2d2e2c95ed6b242580df ./cmd/himesan/main_test.go
1ecbba46f8b1b2d548a01d7e98afae17b2dd17a814338ff1f88db885655d1c07 ./docs/ARCHITECTURE.md
9c598559a89fa4a9bdd2311bd1ed8330992d0a0f74ec8b29ac151fc0ff8fef16 ./docs/BENCHMARKS.md
5c3a62fed80ca28d56558b8c75e8b5be8ba7d2554127adf4609d96da314e85b0 ./docs/BRAND.md
796618a874a53176f7459192c3f5e0aa0c28c2027d4874d2ffa87e285d6692d0 ./docs/COMPATIBILITY.md
2b815d3b815b8d338560183c6f0af46f761c2465309783c93870b8ac8d022d03 ./docs/COMPATIBILITY.md
5f4ac209a16ab110baeaa64a40c19d9239c903e17550c3f05e1e1473ddcc33a3 ./docs/DEVELOPMENT_SERVER.md
51aa57a81131b64f76c45552122de842f22be92d81c8bba8f6fd38a18a7670d6 ./docs/DIAGNOSTICS.md
a62cc7174f3c92d8ef77e4bd9607fbf5d4b80bc514ff05bd433c02a9b0578f18 ./docs/LANGUAGE_SERVER.md
965a6ae57a8162c3af81f4987771e88617247f903088d210720ea2afab152cc9 ./docs/SECURITY_EVIDENCE.md
d969c7b5486ee93e54232fd69d9db06f3b4dc1bba63001596ec48545073c2680 ./docs/THREAT_MODEL.md
738258ba8f7e5ffea67d3f00eb70839590171971a9946a55b013ca95baf7aafb ./docs/V1_RELEASE_PLAN.md
f27c46ca63707bb8cc570eab1ea521824e94bc59b1d153998a5e91c2c7340d16 ./go.mod
ca0bf5051d356d2602f46201fb1637ce48b629ad42161877eec13f743f215dc5 ./internal/compiler/abi_test.go
d891b9b075617050471b2ca34de73d926aaebde4ec638a5039b0d5001d3172f4 ./internal/compiler/analysis.go
c123bf8c8fd847962e147041172b71fef7e852819ccd7b27d4648748e02d522f ./internal/compiler/analysis_test.go
6ef6a0f15a5aca1c8708cbf24218372e1fca9c6fead1a5a75d261faa69651af7 ./internal/compiler/backend.go
4d309392a7739b26e41cd9d9db71b52077bb8198573634ddf793ad531acc81cf ./internal/compiler/compiler_test.go
dbba23e360bd6dd1e8f42953a49a7cfcc241aa3ac76f5ce505ec8f8558833c84 ./internal/compiler/context.go
b2a96ef1ad572ad9cd0e9247328ca261de6f9f3689da41e3f3e111d405a6dee6 ./internal/compiler/diagnostic.go
45562a41ef9ab1116746e4962ce8f93d4d8651e1e468a38122c626f8a34a2874 ./internal/compiler/discover.go
6a3dccd0c7caf92fe2690b350f1db6f34037609b1fff6d3e68e0e229241950e5 ./internal/compiler/discover.go
50a8bad11deea99faf0c7c9de1266bc086b7a1247ff9dd275ffedfc69a3be81d ./internal/compiler/e2e_test.go
eefb05a35bd07660a293c8af97949cd6f69a22709728f3fe2cc9132b863b7d5a ./internal/compiler/fuzz_test.go
d166096f185d76b2698aa3ab3251f00e58f84cdedc3af667e88ddd528ca0cb76 ./internal/compiler/model.go
440580fcd844b2c80b1f4dddc4d83db82974e203cca298387a190e53e6a524f3 ./internal/compiler/model.go
01516c0330b14a1970019690d8efcc9004d2dcfa59e82fef1b67f98005b67532 ./internal/compiler/operation.go
d7d8181455d5f37ef9bcc6bdbf86e0630f20e8a5b3b81688d12742687b434c99 ./internal/compiler/parse.go
1f633caa019d8de9ae5bbad27e29679fcc01b3cb9a33d7d616be07140e06f38b ./internal/compiler/parse.go
80cf170514a3b955d24440cb086d34e19f3a305510e3c5db95cb897be91f922a ./internal/compiler/replace_unix.go
0fff1c67447bf5353ed1df6e7dfc4b14581b67adc1bf02f7a4a7c1f2680c392f ./internal/compiler/replace_windows.go
f4ba01010ed5f5ba1e979702d82e95312bc0a4b13cc205c098926839be4ecb73 ./internal/compiler/testdata/golden/basic.sando
@@ -58,11 +61,18 @@ c0f76ef5c14b0a28ed1e68d8d518102ffcbf067285b087eed4d13cd3c87b0e00 ./internal/dev
7f1efbefea3a277f0f4d96a29219293efd78d9dc44823c09b9667b19d5042047 ./internal/devserver/proxy.go
aebf8388576d7bc9b047ceedf8a893acb3ace5fe16f44cb883efe63eef072ef9 ./internal/devserver/proxy_test.go
e6561e693138a3b77be06c1a98999e71494bbca0d0c72ccb9bff57b8e8575c0f ./internal/devserver/supervisor.go
b94103cd4b582968cdb0b61b0164f57ade006fa4e5187fcaa05944274192526a ./internal/devserver/supervisor_test.go
5c0ce1eedb08c0381dd048526e9f781a2ac8501cc1142648c43d4aa07ca62299 ./internal/devserver/supervisor_test.go
e0a682c0153bf4f2a1f26cc6095d7893ad96e6199cbe76d0150785fc996f1141 ./internal/devserver/watch.go
b7a7fabf9a6c497f7ac2262628c5fb37a6bd00da676e1b7d5088d5f649c9f14c ./internal/devserver/watch_test.go
76609ee5c57b1c174076689d4953e6ec5ef914ca114c27f1ca1d6e7fcabbddcf ./internal/lsp/features.go
edd377bca36a04ae76620a146f986e25c954fd137bab0b0e49084dbcde216ce7 ./internal/lsp/protocol.go
73b2ff8d6fd8fe44abec287384f0051520fe9564a50b4764538474f336624eee ./internal/lsp/protocol_test.go
2a2151a0211a5048c3bc89f9a2901a3c208609f483e99144f3837f87941efff9 ./internal/lsp/security_test.go
3be838b4bb0b618f46c0eab40788a1e03121da25644dedfa7542bd0fa64571e1 ./internal/lsp/server.go
c919b132373d9ab715cdaf057789a3d23f27b1c9f0eded14c06d04d2a4c86ee3 ./internal/lsp/server_test.go
4c5ade5f649dffdd0a1b0a350c15488fc601b7eba582386f39e64412387b5c91 ./internal/lsp/workspace.go
d8c6f37c94ef426fc2d95c82331265f7d700d2e2a23100ad78c92849280ff6d8 ./internal/version/version.go
9b615038a1178eedbcab299d90548e259ac34d2f5c51deadd79ed16f51ef6911 ./internal/version/version_test.go
a1ee0ec44dc45f01a3357cf93e13a81587af7f061497c25df374e2a88231f3b7 ./internal/version/version_test.go
e8a3026ec920d7312f843e2001e50ae4e34fd1ba5f9b2ae25a6113de1fa88385 ./sando/COPYRIGHT
c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 ./sando/LICENSE
b4a7bffe678a97209881e07989563a5085aa0ead9e1b67306087dac6b97bad70 ./sando/README.md
@@ -74,7 +84,7 @@ ff76daee5b642ad84af31701833246d68b54d09580192312d750a7a2e893a692 ./sando/go.mod
b188917e258890e6b6e4840a6fd946fc9a77cabc2068da3764f221e4a6a5df97 ./sando/write_test.go
c4a161faba46ce5b508c0788078256a520277a573a3ace0e85ae0c26b16d298b ./scripts/README.md
0bc796f71c863aa898674a26c56f055e3d81cf20629ca7b32fbae87d8841e0a8 ./scripts/check-licenses.sh
6c73ad46beb642836ae4d462f40e7ecc8d86a3cc194e71a5d0859dac73af0410 ./scripts/release-check.sh
1b003062799b99bfe271b47438397a8cce5875c60c982a0117eb11c3babcadf0 ./scripts/release-check.sh
78a64c7fb3a039b15a1d08b4c0b873952852287a07f670247b081e59dbb09a30 ./scripts/verify-public-install.sh
24ed3c9a1d37e46a856cbbd68e5c58ae04c6c9852902b99ed675e1f428339a9f ./scripts/verify.ps1
f0cbd86759fa729064cb1c69991db2ac291792dadb6b1e1ba83794f2e390404d ./scripts/verify.sh
+19 -12
View File
@@ -42,7 +42,7 @@ request object, or production server.
## Status
`v1.0.0-beta.1` is the first installable public beta. It is supported for
`v1.0.0-beta.2` is the current compiler beta. It is supported for
learning, classroom projects, evaluation, and compatibility feedback. It is a
semantic-version prerelease: source syntax, generated output, the runtime API,
and CLI behavior may change before final v1, and this beta is not recommended
@@ -62,15 +62,14 @@ Inside an application module, add the small runtime first:
go get gamertan.com/sandwich-hime/sando@v1.0.0-beta.1
```
Then install the beta compiler:
Then install the current beta compiler:
```sh
go install gamertan.com/sandwich-hime/cmd/himesan@v1.0.0-beta.1
go install gamertan.com/sandwich-hime/cmd/himesan@v1.0.0-beta.2
```
Keep that runtime-first order for Beta 1. It avoids a Go module-cache ambiguity
between the parent compiler module and its nested runtime when both use the
same prerelease version.
Keep that runtime-first order. It avoids path-selection ambiguity between the
parent compiler module and its independently tagged nested runtime.
If the compiler was installed first and `go get` reports that the parent module
does not contain `sando`, seed the exact nested module without clearing the
@@ -85,14 +84,14 @@ For a reproducible one-off or classroom invocation that does not depend on the
learner's `PATH`:
```sh
go run gamertan.com/sandwich-hime/cmd/himesan@v1.0.0-beta.1 --help
go run gamertan.com/sandwich-hime/cmd/himesan@v1.0.0-beta.2 --help
```
The runtime was released first as `sando/v1.0.0-beta.1`; the compiler followed
as `v1.0.0-beta.1`. Both signed tags, direct fetching, the public Go proxy, and
the checksum database have been verified. A newly announced future version may
still need a short propagation interval before every proxy sees its immutable
tag.
The unchanged runtime remains `sando/v1.0.0-beta.1`; compiler versions are
independent. Beta 2 adds `himesan lsp --stdio` without changing the runtime ABI.
Signed compiler tags, direct fetching, the public Go proxy, and the checksum
database are verified after publication. A newly announced version may still
need a short propagation interval before every proxy sees its immutable tag.
For repository development:
@@ -110,6 +109,14 @@ and serves it through `http://127.0.0.1:7331` with local-only reload
diagnostics. That is a Cole-shaped convenience, not a production server or a
requirement. Take the paved path—or don't.
Beta 2 also provides a standard, editor-neutral language server. It analyzes
unsaved overlays with the compiler's real parser and context model, but never
generates, runs Go, executes a project, fetches a module, accesses the network,
or starts the dev supervisor. See
[the language-server contract](docs/LANGUAGE_SERVER.md). The portable Agent
Skill and VS Code preview live in the separate
[tooling repository](https://gitea.speelman.ca/gamertan/sandwich-hime-tooling).
Final-v1 installs will use the same paths with `@v1.0.0`. A version is
advertised as available only after its immutable tags, `gamertan.com`
metadata, and clean direct-fetch installation have been verified.
+30
View File
@@ -78,6 +78,36 @@ Beta 1 was published from public commit
matrix passed. Future prereleases require their own candidate evidence; this
result cannot be relabeled for another commit.
## Beta 2 compiler publication gates
`v1.0.0-beta.2` is compiler-only. Do not create or move a runtime tag when the
`sando` subtree is byte-identical to `sando/v1.0.0-beta.1`.
Run its technical preflight with the retained runtime made explicit:
```sh
./scripts/release-check.sh \
--version v1.0.0-beta.2 \
--runtime-tag sando/v1.0.0-beta.1
```
In addition to every Beta 1 compiler/security/determinism gate:
1. verify protocol framing, malformed messages, shutdown, request
cancellation, UTF-16 positions, CRLF/NUL handling, bounded fuzz smoke,
overlays, source appearance/deletion, duplicates, cycles, symlink and
nested-module boundaries, completion scope, and component definitions;
2. prove the language-server package does not write, execute project code,
invoke Go, fetch, access the network, or start the development supervisor;
3. run the exact candidate on supported Go lines under executed Linux and
native Windows, with native macOS status stated explicitly;
4. build an exact version-stamped candidate and assert the additive
`features: ["lsp-stdio"]` JSON identity;
5. publish a signed annotated compiler tag only after the reviewed sanitized
public commit is current; and
6. verify direct, public-proxy, and checksum-database installation without
altering `v1.0.0-beta.1` or `sando/v1.0.0-beta.1`.
## RC and final gates
No release candidate or v1.0.0 release occurs until every applicable gate in
+11 -4
View File
@@ -2,10 +2,11 @@
# Security policy
Sandwich Hime `v1.0.0-beta.1` is supported within a deliberately narrow
scope: learning, classroom projects, evaluation, and compatibility testing.
Sandwich Hime compiler `v1.0.0-beta.2` and runtime
`sando/v1.0.0-beta.1` are supported within a deliberately narrow scope:
learning, classroom projects, evaluation, and compatibility testing.
Security reports are welcome and receive best-effort maintainer assessment and
fixes while Beta 1 is the current prerelease. This is not production support,
fixes while this pair is current. This is not production support,
an SLA, a fitness guarantee, or a promise that a fix will preserve beta APIs.
The community is invited to help find compatibility gaps, especially on macOS.
@@ -17,7 +18,7 @@ advisories, and release decisions.
| Version | Security status |
| --- | --- |
| `v1.0.0-beta.1` and `sando/v1.0.0-beta.1` | Current published evaluation/classroom prerelease; best-effort security assessment and fixes; interfaces may change |
| `v1.0.0-beta.2` compiler and `sando/v1.0.0-beta.1` runtime | Current evaluation/classroom prerelease pair; best-effort security assessment and fixes; interfaces may change |
| Public `main` | Development source; reports welcome, but no compatibility or production-support promise |
| Older prereleases | Superseded when a newer prerelease or final version is published; reports are still triaged to determine affected versions |
@@ -133,6 +134,12 @@ an independent security audit, certification, or formal verification. Coverage
percentages, passing scanners, and a clean vulnerability database result are
evidence of specific checks—not proof that no vulnerability exists.
The Beta 2 language server is read-only and standard-library-only. Its tests
exercise bounded framing, malformed messages, cancellation, UTF-16 positions,
unsaved overlays, workspace re-indexing, and no generated writes. Source review
forbids process execution and network-client imports inside the server. Editor
workspace trust and executable resolution remain editor-client responsibilities.
Beta 1 uses signed annotated source tags, but precedes the
complete prebuilt-artifact and key-recovery system. Signed binaries, checksums,
an SBOM, reproducible archives, and complete source/build provenance are
+27 -1
View File
@@ -18,6 +18,7 @@ import (
"gamertan.com/sandwich-hime/internal/compiler"
"gamertan.com/sandwich-hime/internal/devserver"
"gamertan.com/sandwich-hime/internal/lsp"
"gamertan.com/sandwich-hime/internal/version"
)
@@ -38,6 +39,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return runCompilerCommand(ctx, args[0], args[1:], stdout, stderr, compiler.Check)
case "dev":
return runDev(ctx, args[1:], stdout, stderr)
case "lsp":
return runLSP(ctx, args[1:], os.Stdin, stdout, stderr)
case "version":
return runVersion(args[1:], stdout, stderr)
case "help", "-h", "--help":
@@ -119,7 +122,8 @@ func runVersion(args []string, stdout, stderr io.Writer) int {
Compiler string `json:"compiler"`
RuntimeABI string `json:"runtime_abi"`
Go string `json:"go"`
}{Compiler: version.Compiler, RuntimeABI: version.RuntimeABI, Go: runtime.Version()}
Features []string `json:"features"`
}{Compiler: version.Compiler, RuntimeABI: version.RuntimeABI, Go: runtime.Version(), Features: []string{"lsp-stdio"}}
if *jsonOutput {
if err := json.NewEncoder(stdout).Encode(information); err != nil {
fmt.Fprintf(stderr, "himesan: encode version: %v\n", err)
@@ -131,6 +135,27 @@ func runVersion(args []string, stdout, stderr io.Writer) int {
return 0
}
func runLSP(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
flags := flag.NewFlagSet("lsp", flag.ContinueOnError)
flags.SetOutput(stderr)
stdio := flags.Bool("stdio", false, "serve Language Server Protocol JSON-RPC over stdin/stdout")
if err := flags.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if !*stdio || flags.NArg() != 0 {
fmt.Fprintln(stderr, "himesan lsp requires exactly --stdio")
return 2
}
if err := lsp.Run(ctx, lsp.Options{Input: stdin, Output: stdout, LogOutput: stderr}); err != nil {
fmt.Fprintf(stderr, "himesan lsp: %v\n", err)
return 1
}
return 0
}
type stringList []string
func (values *stringList) String() string { return strings.Join(*values, ",") }
@@ -312,6 +337,7 @@ func printHelp(output io.Writer) {
fmt.Fprintln(output, " himesan check [--json] [paths...] validate sources and committed output without writes")
fmt.Fprintln(output, " himesan bless [--json] [paths...] friendly read-only alias for check")
fmt.Fprintln(output, " himesan dev [flags] [package] [-- app-args...] run the loopback last-good supervisor")
fmt.Fprintln(output, " himesan lsp --stdio run the read-only language server")
fmt.Fprintln(output, " himesan version [--json] print compiler and runtime ABI versions")
fmt.Fprintln(output)
fmt.Fprintln(output, "Templates use .sando; .san remains exclusively San language source.")
+12 -2
View File
@@ -30,14 +30,24 @@ func TestRunHelpVersionAndUnknownCommand(t *testing.T) {
if code := run(context.Background(), []string{"version", "--json"}, &stdout, &stderr); code != 0 {
t.Fatalf("version exit code = %d: %s", code, stderr.String())
}
var versionResult map[string]string
var versionResult struct {
Compiler string `json:"compiler"`
RuntimeABI string `json:"runtime_abi"`
Features []string `json:"features"`
}
if err := json.Unmarshal(stdout.Bytes(), &versionResult); err != nil {
t.Fatalf("version JSON: %v", err)
}
if versionResult["compiler"] == "" || versionResult["runtime_abi"] != compiler.RuntimeABI {
if versionResult.Compiler == "" || versionResult.RuntimeABI != compiler.RuntimeABI || len(versionResult.Features) != 1 || versionResult.Features[0] != "lsp-stdio" {
t.Fatalf("version result = %#v", versionResult)
}
stdout.Reset()
stderr.Reset()
if code := run(context.Background(), []string{"lsp"}, &stdout, &stderr); code != 2 {
t.Fatalf("lsp without --stdio exit code = %d, want 2", code)
}
stdout.Reset()
stderr.Reset()
if code := run(context.Background(), []string{"rebuke"}, &stdout, &stderr); code != 2 {
+3 -3
View File
@@ -2,9 +2,9 @@
# Compatibility policy
## Beta 1 contract
## Beta contract
`v1.0.0-beta.1` and `sando/v1.0.0-beta.1` are semantic-version
Compiler `v1.0.0-beta.2` and runtime `sando/v1.0.0-beta.1` are semantic-version
prereleases. They are supported for learning, classroom projects, evaluation,
and compatibility testing. Before final v1, source syntax, generated output,
the runtime API, CLI behavior, diagnostics, and configuration may change
@@ -32,7 +32,7 @@ payloads before final v1, or hand-edited generated files.
## Go and platform support
Beta 1 targets Go 1.25 and Go 1.26. Support is based on point-in-time,
The current beta targets Go 1.25 and Go 1.26. Support is based on point-in-time,
maintainer-run release matrices, not an implication of continuous CI coverage.
A Go support change is announced in release notes before it takes effect.
+51
View File
@@ -0,0 +1,51 @@
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# Hime-san language server
Hime-san `v1.0.0-beta.2` adds a reusable, read-only Language Server Protocol
surface:
```sh
himesan lsp --stdio
```
The server accepts one local workspace root per process. Editors with multiple
workspace folders start one process for each folder. Standard output contains
only framed JSON-RPC; bounded operational messages go to standard error and do
not include template source, environment values, or secrets.
## Beta 2 capabilities
- full-document synchronization and unsaved in-memory overlays;
- live compiler diagnostics, trust warnings, duplicate components, and
statically knowable component cycles;
- UTF-16 protocol positions without changing compiler CLI byte coordinates;
- hover help for tags, inferred output contexts, component signatures, and
trusted-output boundaries;
- document symbols for the declared component and template regions;
- delimiter/tag completion plus same-package and already-imported component
completion; and
- component go-to-definition.
The index honors the compiler's symlink, nested-module, VCS, vendor, and
filesystem boundaries. Open/save is analyzed immediately; ordinary edits are
debounced for 200 ms and superseded analyses are canceled. Appearance,
deletion, rename, and save notifications rebuild the bounded source index.
## Deliberate exclusions
The language server does not generate files, report generated-file freshness,
invoke Go or `gopls`, execute project code, fetch dependencies, access the
network, or start `himesan dev`. It provides no general Go or HTML completion,
formatting, rename, references, automatic imports, or live browser preview.
Use explicit `himesan check --json` for committed-output freshness and normal
Go tests/builds for type checking. Editors remain responsible for workspace
trust, process startup, and user-visible command policy.
## Resource limits
Protocol frames and individual documents are limited to 16 MiB, the indexed
workspace source set to 64 MiB and 10,000 `.sando` files. These are denial-of-
service guardrails for trusted local workspaces, not a sandbox for hostile
template authors.
+163
View File
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"context"
"fmt"
"path/filepath"
"sort"
)
// SourceInput supplies one in-memory .sando document for editor analysis.
type SourceInput struct {
Path string
Source []byte
}
// AnalysisImport describes one import already present in a .sando header.
type AnalysisImport struct {
Alias string `json:"alias,omitempty"`
Path string `json:"path"`
}
// AnalysisRegionKind identifies an author-visible template region.
type AnalysisRegionKind string
const (
AnalysisStatement AnalysisRegionKind = "statement"
AnalysisExpression AnalysisRegionKind = "expression"
AnalysisComponent AnalysisRegionKind = "component"
AnalysisComment AnalysisRegionKind = "comment"
)
// AnalysisRegion describes a Hime-san tag body using zero-based byte offsets.
// Line and Column retain the compiler's one-based byte-coordinate convention;
// protocol adapters convert them to UTF-16 where required.
type AnalysisRegion struct {
Kind AnalysisRegionKind `json:"kind"`
Text string `json:"text,omitempty"`
Context Context `json:"context,omitempty"`
Offset int `json:"offset"`
Length int `json:"length"`
Line int `json:"line"`
Column int `json:"column"`
}
// DocumentAnalysis is the compiler-owned semantic description consumed by
// read-only tools such as the language server.
type DocumentAnalysis struct {
Path string `json:"path"`
Package string `json:"package,omitempty"`
Component string `json:"component,omitempty"`
TypeParams string `json:"type_params,omitempty"`
Params string `json:"params,omitempty"`
Signature string `json:"signature,omitempty"`
ComponentOffset int `json:"component_offset,omitempty"`
ComponentLine int `json:"component_line,omitempty"`
ComponentColumn int `json:"component_column,omitempty"`
Imports []AnalysisImport `json:"imports,omitempty"`
Regions []AnalysisRegion `json:"regions,omitempty"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
}
// AnalyzeSources applies the normal parser, HTML-context analyzer, trust
// audit, backend validation, duplicate-component checks, and statically
// knowable cycle checks to an in-memory source set. It performs no I/O.
func AnalyzeSources(ctx context.Context, inputs []SourceInput) []DocumentAnalysis {
ordered := append([]SourceInput(nil), inputs...)
sort.SliceStable(ordered, func(i, j int) bool {
return filepath.Clean(ordered[i].Path) < filepath.Clean(ordered[j].Path)
})
analyses := make([]DocumentAnalysis, 0, len(ordered))
compiled := make([]CompiledFile, 0, len(ordered))
for _, input := range ordered {
if err := ctx.Err(); err != nil {
analyses = append(analyses, DocumentAnalysis{
Path: filepath.Clean(input.Path),
Diagnostics: []Diagnostic{diagnostic(input.Path, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error())},
})
continue
}
analysis, output := analyzeSource(input.Path, input.Source)
analyses = append(analyses, analysis)
if output.Code != nil {
compiled = append(compiled, output)
}
}
byPath := make(map[string][]Diagnostic)
for _, item := range detectComponentCycles(compiled) {
path := filepath.Clean(item.Path)
byPath[path] = append(byPath[path], item)
}
for index := range analyses {
path := filepath.Clean(analyses[index].Path)
analyses[index].Diagnostics = append(analyses[index].Diagnostics, byPath[path]...)
sortDiagnostics(analyses[index].Diagnostics)
}
return analyses
}
func analyzeSource(path string, source []byte) (DocumentAnalysis, CompiledFile) {
cleanPath := filepath.Clean(path)
analysis := DocumentAnalysis{Path: cleanPath}
file, diagnostics := parseSource(cleanPath, source)
if file == nil {
sortDiagnostics(diagnostics)
analysis.Diagnostics = diagnostics
return analysis, CompiledFile{}
}
analysis.Package = file.Package
analysis.Component = file.Name
analysis.TypeParams = file.TypeParams
analysis.Params = file.Params
analysis.Signature = fmt.Sprintf("func %s%s%s", file.Name, file.TypeParams, file.Params)
analysis.ComponentOffset = file.FunctionPos.Offset
analysis.ComponentLine = file.FunctionPos.Line
analysis.ComponentColumn = file.FunctionPos.Column
for _, imported := range file.Imports {
analysis.Imports = append(analysis.Imports, AnalysisImport{Alias: imported.Alias, Path: imported.Path})
}
diagnostics = append(diagnostics, analyzeContexts(file)...)
diagnostics = append(diagnostics, auditTrustCalls(file)...)
for _, node := range file.Nodes {
kind := AnalysisRegionKind("")
switch node.Kind {
case nodeStatement:
kind = AnalysisStatement
case nodeExpression:
kind = AnalysisExpression
case nodeComponent:
kind = AnalysisComponent
case nodeComment:
kind = AnalysisComment
default:
continue
}
analysis.Regions = append(analysis.Regions, AnalysisRegion{
Kind: kind, Text: node.Text, Context: node.Context,
Offset: node.Pos.Offset, Length: len(node.Text),
Line: node.Pos.Line, Column: node.Pos.Column,
})
}
if hasErrors(diagnostics) {
sortDiagnostics(diagnostics)
analysis.Diagnostics = diagnostics
return analysis, CompiledFile{}
}
code, backendDiagnostics := generateGo(file)
diagnostics = append(diagnostics, backendDiagnostics...)
sortDiagnostics(diagnostics)
analysis.Diagnostics = diagnostics
if hasErrors(diagnostics) {
return analysis, CompiledFile{}
}
return analysis, CompiledFile{
SourcePath: cleanPath,
OutputPath: cleanPath + ".go",
Package: file.Package,
Component: file.Name,
Code: code,
source: file,
}
}
+95
View File
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAnalyzeSourcesUsesCompilerSemanticsWithoutIO(t *testing.T) {
t.Parallel()
directory := t.TempDir()
firstPath := filepath.Join(directory, "first.sando")
secondPath := filepath.Join(directory, "second.sando")
first := []byte("<?sando go\npackage views\nfunc First(name string)\n?>\n<div><?= name ?><?~ Second() ?></div>\n")
second := []byte("<?sando go\npackage views\nfunc Second()\n?>\n<section><?~ First(\"again\") ?></section>\n")
analyses := AnalyzeSources(context.Background(), []SourceInput{{Path: firstPath, Source: first}, {Path: secondPath, Source: second}})
if len(analyses) != 2 {
t.Fatalf("analysis count = %d, want 2", len(analyses))
}
for _, analysis := range analyses {
if analysis.Component == "" || analysis.Signature == "" || analysis.ComponentLine < 1 {
t.Fatalf("missing component metadata: %#v", analysis)
}
if !hasDiagnosticCode(analysis.Diagnostics, "HIM1501") {
t.Fatalf("cycle diagnostic missing for %s: %#v", analysis.Component, analysis.Diagnostics)
}
if _, err := os.Stat(analysis.Path + ".go"); !os.IsNotExist(err) {
t.Fatalf("analysis wrote generated output: %v", err)
}
}
if analyses[0].Regions[0].Context != ContextHTMLText {
t.Fatalf("expression context = %q, want %q", analyses[0].Regions[0].Context, ContextHTMLText)
}
}
func TestAnalyzeSourcesDuplicateAndMalformedDocuments(t *testing.T) {
t.Parallel()
directory := t.TempDir()
duplicate := "<?sando go\npackage views\nfunc Card()\n?>\n<p>card</p>\n"
malformed := "<?sando go\npackage views\nfunc Broken()\n?>\n<div>\x00"
analyses := AnalyzeSources(context.Background(), []SourceInput{
{Path: filepath.Join(directory, "a.sando"), Source: []byte(duplicate)},
{Path: filepath.Join(directory, "b.sando"), Source: []byte(duplicate)},
{Path: filepath.Join(directory, "broken.sando"), Source: []byte(malformed)},
})
if !hasDiagnosticCode(analyses[0].Diagnostics, "HIM1500") || !hasDiagnosticCode(analyses[1].Diagnostics, "HIM1500") {
t.Fatalf("duplicate diagnostics missing: %#v", analyses)
}
if !hasDiagnosticCode(analyses[2].Diagnostics, "HIM1002") {
t.Fatalf("NUL diagnostic missing: %#v", analyses[2].Diagnostics)
}
}
func TestDiscoverSourcesOmitsGeneratedFreshnessButKeepsBoundaries(t *testing.T) {
t.Parallel()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/root\n\ngo 1.25\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "orphan.sando.go"), []byte(generatedPrefix+"\npackage root\n"), 0o600); err != nil {
t.Fatal(err)
}
nested := filepath.Join(root, "nested")
if err := os.Mkdir(nested, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "go.mod"), []byte("module example.test/nested\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "hidden.sando"), []byte("ignored"), 0o600); err != nil {
t.Fatal(err)
}
paths, diagnostics := DiscoverSources(context.Background(), []string{root})
if len(paths) != 0 {
t.Fatalf("discovered nested source: %v", paths)
}
for _, item := range diagnostics {
if item.Code == "HIM2014" {
t.Fatalf("editor discovery reported generated freshness: %#v", diagnostics)
}
}
}
func hasDiagnosticCode(diagnostics []Diagnostic, code string) bool {
for _, item := range diagnostics {
if item.Code == code || strings.HasPrefix(item.Code, code) {
return true
}
}
return false
}
+14
View File
@@ -23,6 +23,17 @@ var excludedDirectories = map[string]bool{
}
func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
return discoverWithOptions(ctx, paths, true)
}
// DiscoverSources finds .sando sources using the same filesystem, symlink,
// nested-module, VCS, and vendor boundaries as Generate and Check. It omits
// generated-output inspection because editor analysis does not own freshness.
func DiscoverSources(ctx context.Context, paths []string) ([]string, []Diagnostic) {
return discoverWithOptions(ctx, paths, false)
}
func discoverWithOptions(ctx context.Context, paths []string, inspectGenerated bool) ([]string, []Diagnostic) {
if len(paths) == 0 {
paths = []string{"."}
}
@@ -121,6 +132,9 @@ func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
return nil
}
if strings.HasSuffix(entry.Name(), ".sando.go") {
if !inspectGenerated {
return nil
}
entryInfo, statErr := entry.Info()
if statErr != nil {
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2013", "cannot inspect possible generated output: "+statErr.Error()))
+1
View File
@@ -69,6 +69,7 @@ type sourceFile struct {
Nodes []rendererNode
Source []byte
HeaderEnd int
FunctionPos sourcePosition
AST *ast.File
}
+3
View File
@@ -123,6 +123,7 @@ func parseSource(path string, source []byte) (*sourceFile, []Diagnostic) {
Imports: parsedHeader.Imports,
Source: source,
HeaderEnd: headerClose + 2,
FunctionPos: parsedHeader.FunctionPos,
AST: parsedHeader.AST,
}
@@ -144,6 +145,7 @@ type parsedHeader struct {
TypeParams string
Params string
Imports []sourceImport
FunctionPos sourcePosition
AST *ast.File
}
@@ -259,6 +261,7 @@ func parseHeader(path string, declarations []byte, sourceOffset int, table posit
TypeParams: typeParams,
Params: params,
Imports: imports,
FunctionPos: table.at(sourceOffset + functionPosition.Offset),
AST: parsed,
}, diagnostics
}
+19 -2
View File
@@ -23,6 +23,7 @@ func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
t.Skip("integration test builds temporary Go applications")
}
root := t.TempDir()
disableParentVCSStamping(t)
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/himesan-dev-test\n\ngo 1.25\n"), 0o600); err != nil {
t.Fatal(err)
}
@@ -41,7 +42,10 @@ func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
generations.Add(1)
return nil
},
OnEvent: func(event Event) { events <- event },
OnEvent: func(event Event) {
t.Logf("supervisor event: type=%s phase=%s message=%s", event.Type, event.Phase, event.Message)
events <- event
},
CacheDir: filepath.Join(t.TempDir(), "cache"),
PollInterval: 25 * time.Millisecond,
Debounce: 25 * time.Millisecond,
@@ -101,6 +105,7 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
t.Skip("integration test builds a temporary Go application")
}
root := t.TempDir()
disableParentVCSStamping(t)
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/himesan-dev-exit-test\n\ngo 1.25\n"), 0o600); err != nil {
t.Fatal(err)
}
@@ -115,7 +120,10 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
RootDir: root,
Config: cfg,
Generate: func(context.Context) error { return nil },
OnEvent: func(event Event) { events <- event },
OnEvent: func(event Event) {
t.Logf("supervisor event: type=%s phase=%s message=%s", event.Type, event.Phase, event.Message)
events <- event
},
CacheDir: filepath.Join(t.TempDir(), "cache"),
PollInterval: 30 * time.Second,
Debounce: 25 * time.Millisecond,
@@ -163,6 +171,15 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
}
}
func disableParentVCSStamping(t *testing.T) {
t.Helper()
// A temporary standalone module can live under a parent directory that is
// itself a VCS checkout (including hardened test sandboxes). Its candidate
// must not inherit or depend on that unrelated repository's status.
flags := strings.TrimSpace(os.Getenv("GOFLAGS") + " -buildvcs=false")
t.Setenv("GOFLAGS", flags)
}
func TestGenerationFailureDoesNotMoveProxyTarget(t *testing.T) {
t.Parallel()
upstream := http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+304
View File
@@ -0,0 +1,304 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"bytes"
"fmt"
"strings"
"unicode"
"unicode/utf8"
"gamertan.com/sandwich-hime/internal/compiler"
)
type completionItem struct {
Label string `json:"label"`
Kind int `json:"kind,omitempty"`
Detail string `json:"detail,omitempty"`
Documentation any `json:"documentation,omitempty"`
InsertText string `json:"insertText,omitempty"`
InsertTextFormat int `json:"insertTextFormat,omitempty"`
SortText string `json:"sortText,omitempty"`
}
type markupContent struct {
Kind string `json:"kind"`
Value string `json:"value"`
}
type hoverResult struct {
Contents markupContent `json:"contents"`
Range *Range `json:"range,omitempty"`
}
type documentSymbol struct {
Name string `json:"name"`
Detail string `json:"detail,omitempty"`
Kind int `json:"kind"`
Range Range `json:"range"`
SelectionRange Range `json:"selectionRange"`
Children []documentSymbol `json:"children,omitempty"`
}
func (server *Server) completions(request textDocumentPositionParams) any {
document, analysis, ok := server.snapshotDocument(request.TextDocument.URI)
if !ok {
return []completionItem{}
}
offset, ok := positionToOffset(document.Text, request.Position)
if !ok {
return []completionItem{}
}
open := bytes.LastIndex(document.Text[:offset], []byte("<?"))
close := bytes.LastIndex(document.Text[:offset], []byte("?>"))
if open >= 0 && open > close && bytes.HasPrefix(document.Text[open:offset], []byte("<?~")) {
server.mu.RLock()
snapshot := server.snapshot
server.mu.RUnlock()
prefix := strings.TrimSpace(string(document.Text[open+3 : offset]))
items := make([]completionItem, 0)
for _, target := range snapshot.componentsFor(document.Path, analysis) {
label := target.Label()
if prefix != "" && !strings.HasPrefix(label, prefix) {
continue
}
items = append(items, completionItem{
Label: label, Kind: 3, Detail: target.Signature,
Documentation: markupContent{Kind: "markdown", Value: "Typed `.sando` component. Hime-san emits an ordinary Go constructor."},
InsertText: label, InsertTextFormat: 1, SortText: "1-" + label,
})
}
return struct {
IsIncomplete bool `json:"isIncomplete"`
Items []completionItem `json:"items"`
}{Items: items}
}
return struct {
IsIncomplete bool `json:"isIncomplete"`
Items []completionItem `json:"items"`
}{Items: tagCompletions()}
}
func tagCompletions() []completionItem {
return []completionItem{
{Label: "<?sando go", Kind: 15, Detail: "component file header", InsertText: "<?sando go\npackage ${1:views}\nfunc ${2:Component}(${3:})\n?>", InsertTextFormat: 2, SortText: "0-header"},
{Label: "<? … ?>", Kind: 15, Detail: "Go statement", InsertText: "<? ${1:if condition {} } ?>", InsertTextFormat: 2, SortText: "0-statement"},
{Label: "<?= … ?>", Kind: 15, Detail: "contextually escaped expression", InsertText: "<?= ${1:value} ?>", InsertTextFormat: 2, SortText: "0-expression"},
{Label: "<?~ … ?>", Kind: 15, Detail: "typed component composition", InsertText: "<?~ ${1:Component()} ?>", InsertTextFormat: 2, SortText: "0-component"},
{Label: "<?# … ?>", Kind: 15, Detail: "Hime-san template comment", InsertText: "<?# ${1:comment} ?>", InsertTextFormat: 2, SortText: "0-comment"},
}
}
func (server *Server) hover(request textDocumentPositionParams) any {
document, analysis, ok := server.snapshotDocument(request.TextDocument.URI)
if !ok {
return nil
}
offset, ok := positionToOffset(document.Text, request.Position)
if !ok {
return nil
}
for _, region := range analysis.Regions {
if offset < region.Offset || offset > region.Offset+region.Length {
continue
}
if region.Kind == compiler.AnalysisComponent {
qualifier, name := referenceAt(region.Text, offset-region.Offset)
if name != "" {
if target, found := server.resolveComponent(document.Path, analysis, qualifier, name); found {
value := "```go\n" + target.Signature + "\n```\n\nTyped component composition. Handwritten `sando.Component` values are trusted output capabilities."
return hoverResult{Contents: markupContent{Kind: "markdown", Value: value}}
}
}
}
value := fmt.Sprintf("**Hime-san %s region**\n\nOutput context: `%s`.", region.Kind, region.Context)
if region.Context == compiler.ContextJS || region.Context == compiler.ContextCSS || strings.Contains(region.Text, "Trust") {
value += "\n\nTrusted output is an explicit security capability; audit its provenance and parser-state effects."
}
return hoverResult{Contents: markupContent{Kind: "markdown", Value: value}}
}
if marker, start, end := enclosingTag(document.Text, offset); marker != "" {
if text := tagDocumentation(marker); text != "" {
rangeValue := Range{Start: offsetToPosition(document.Text, start), End: offsetToPosition(document.Text, end)}
return hoverResult{Contents: markupContent{Kind: "markdown", Value: text}, Range: &rangeValue}
}
}
return nil
}
func tagDocumentation(marker string) string {
switch marker {
case "<?sando":
return "**`<?sando go … ?>`** declares the Go package, imports, and one typed component signature. It must be the first non-whitespace content."
case "<?~":
return "**`<?~ … ?>`** composes a typed `sando.Component` at an HTML content boundary. It is not template inheritance."
case "<?=":
return "**`<?= … ?>`** renders an expression through the helper selected by Hime-san's inferred HTML output context."
case "<?#":
return "**`<?# … ?>`** is a Hime-san comment. It emits no bytes and cannot change HTML parser state."
case "<?":
return "**`<? … ?>`** contains Go statements and is valid only at an HTML content boundary."
}
return ""
}
func enclosingTag(text []byte, offset int) (string, int, int) {
if offset < 0 || offset > len(text) {
return "", 0, 0
}
open := bytes.LastIndex(text[:offset], []byte("<?"))
if open < 0 {
return "", 0, 0
}
closeRelative := bytes.Index(text[open:], []byte("?>"))
if closeRelative < 0 || open+closeRelative+2 < offset {
return "", 0, 0
}
end := open + closeRelative + 2
marker := "<?"
for _, candidate := range []string{"<?sando", "<?~", "<?=", "<?#"} {
if bytes.HasPrefix(text[open:end], []byte(candidate)) {
marker = candidate
break
}
}
return marker, open, end
}
func (server *Server) definition(request textDocumentPositionParams) any {
document, analysis, ok := server.snapshotDocument(request.TextDocument.URI)
if !ok {
return nil
}
offset, ok := positionToOffset(document.Text, request.Position)
if !ok {
return nil
}
for _, region := range analysis.Regions {
if region.Kind != compiler.AnalysisComponent || offset < region.Offset || offset > region.Offset+region.Length {
continue
}
qualifier, name := referenceAt(region.Text, offset-region.Offset)
if name == "" {
return nil
}
target, found := server.resolveComponent(document.Path, analysis, qualifier, name)
if !found {
return nil
}
server.mu.RLock()
targetDocument, exists := server.snapshot.documents[target.Path]
server.mu.RUnlock()
if !exists {
return nil
}
start := target.Analysis.ComponentOffset
end := start + len(target.Analysis.Signature)
return Location{URI: targetDocument.URI, Range: Range{Start: offsetToPosition(targetDocument.Text, start), End: offsetToPosition(targetDocument.Text, end)}}
}
return nil
}
func (server *Server) resolveComponent(path string, analysis compiler.DocumentAnalysis, qualifier, name string) (componentTarget, bool) {
server.mu.RLock()
snapshot := server.snapshot
server.mu.RUnlock()
for _, target := range snapshot.componentsFor(path, analysis) {
if target.Qualifier == qualifier && target.Name == name {
return target, true
}
}
return componentTarget{}, false
}
func referenceAt(expression string, cursor int) (string, string) {
if cursor < 0 {
cursor = 0
}
if cursor > len(expression) {
cursor = len(expression)
}
if cursor == len(expression) && cursor > 0 {
cursor--
}
for cursor > 0 && cursor < len(expression) && !identifierByte(expression[cursor]) && expression[cursor] != '.' {
cursor--
}
start := cursor
for start > 0 && (identifierByte(expression[start-1]) || expression[start-1] == '.') {
start--
}
end := cursor
for end < len(expression) && (identifierByte(expression[end]) || expression[end] == '.') {
end++
}
reference := strings.Trim(expression[start:end], ".")
parts := strings.Split(reference, ".")
if len(parts) == 1 && validIdentifier(parts[0]) {
return "", parts[0]
}
if len(parts) == 2 && validIdentifier(parts[0]) && validIdentifier(parts[1]) {
return parts[0], parts[1]
}
return "", ""
}
func identifierByte(value byte) bool {
return value == '_' || value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9'
}
func validIdentifier(value string) bool {
if value == "" {
return false
}
for index, r := range value {
if index == 0 && !(r == '_' || unicode.IsLetter(r)) {
return false
}
if index != 0 && !(r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)) {
return false
}
}
return true
}
func (server *Server) documentSymbols(uri string) []documentSymbol {
document, analysis, ok := server.snapshotDocument(uri)
if !ok || analysis.Component == "" {
return []documentSymbol{}
}
documentRange := Range{Start: Position{}, End: offsetToPosition(document.Text, len(document.Text))}
selectionStart := analysis.ComponentOffset
selectionEnd := selectionStart + len(analysis.Component)
children := make([]documentSymbol, 0, len(analysis.Regions))
for index, region := range analysis.Regions {
end := region.Offset + region.Length
name := fmt.Sprintf("%s %d", region.Kind, index+1)
if region.Kind == compiler.AnalysisComponent {
_, componentName := referenceAt(region.Text, 0)
if componentName != "" {
name = "component " + componentName
}
}
rangeValue := Range{Start: offsetToPosition(document.Text, region.Offset), End: offsetToPosition(document.Text, end)}
children = append(children, documentSymbol{Name: name, Detail: string(region.Context), Kind: 13, Range: rangeValue, SelectionRange: rangeValue})
}
return []documentSymbol{{
Name: analysis.Component, Detail: analysis.Signature, Kind: 12,
Range: documentRange,
SelectionRange: Range{Start: offsetToPosition(document.Text, selectionStart), End: offsetToPosition(document.Text, selectionEnd)},
Children: children,
}}
}
func runeEnd(text []byte, offset int) int {
if offset >= len(text) {
return len(text)
}
_, size := utf8.DecodeRune(text[offset:])
if size < 1 {
size = 1
}
return offset + size
}
+174
View File
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"strconv"
"strings"
"sync"
)
const (
maxMessageBytes = 16 << 20
maxHeaderBytes = 64 << 10
maxHeaderLines = 64
)
type rpcMessage struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func validRequestID(id json.RawMessage) bool {
if len(id) == 0 {
return true
}
var value any
if err := json.Unmarshal(id, &value); err != nil {
return false
}
switch value := value.(type) {
case string:
return true
case float64:
return value == math.Trunc(value)
default:
return false
}
}
const (
errParse = -32700
errInvalidRequest = -32600
errMethodNotFound = -32601
errInvalidParams = -32602
errInternal = -32603
errRequestCancelled = -32800
)
type frameReader struct{ reader *bufio.Reader }
func newFrameReader(input io.Reader) *frameReader {
return &frameReader{reader: bufio.NewReaderSize(input, 64<<10)}
}
func (reader *frameReader) read() ([]byte, error) {
contentLength := -1
headerBytes := 0
headerLines := 0
for {
line, err := reader.reader.ReadString('\n')
if err != nil {
return nil, err
}
headerBytes += len(line)
headerLines++
if headerBytes > maxHeaderBytes || headerLines > maxHeaderLines {
return nil, errors.New("LSP headers exceed configured limits")
}
if len(line) > 8<<10 {
return nil, errors.New("LSP header line exceeds 8 KiB")
}
line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
if line == "" {
break
}
name, value, ok := strings.Cut(line, ":")
if !ok {
return nil, errors.New("malformed LSP header")
}
if strings.EqualFold(strings.TrimSpace(name), "Content-Length") {
if contentLength >= 0 {
return nil, errors.New("duplicate Content-Length header")
}
parsed, parseErr := strconv.Atoi(strings.TrimSpace(value))
if parseErr != nil || parsed < 0 || parsed > maxMessageBytes {
return nil, errors.New("invalid or excessive Content-Length")
}
contentLength = parsed
}
}
if contentLength < 0 {
return nil, errors.New("missing Content-Length header")
}
payload := make([]byte, contentLength)
if _, err := io.ReadFull(reader.reader, payload); err != nil {
return nil, err
}
return payload, nil
}
type frameWriter struct {
mu sync.Mutex
output io.Writer
}
func (writer *frameWriter) write(message rpcMessage) error {
payload, err := json.Marshal(message)
if err != nil {
return err
}
var frame bytes.Buffer
fmt.Fprintf(&frame, "Content-Length: %d\r\n\r\n", len(payload))
frame.Write(payload)
writer.mu.Lock()
defer writer.mu.Unlock()
written, err := writer.output.Write(frame.Bytes())
if err == nil && written != frame.Len() {
return io.ErrShortWrite
}
return err
}
type Position struct {
Line int `json:"line"`
Character int `json:"character"`
}
type Range struct {
Start Position `json:"start"`
End Position `json:"end"`
}
type Location struct {
URI string `json:"uri"`
Range Range `json:"range"`
}
type lspDiagnostic struct {
Range Range `json:"range"`
Severity int `json:"severity,omitempty"`
Code string `json:"code,omitempty"`
Source string `json:"source,omitempty"`
Message string `json:"message"`
}
type textDocumentIdentifier struct {
URI string `json:"uri"`
}
type versionedTextDocumentIdentifier struct {
URI string `json:"uri"`
Version int `json:"version"`
}
type textDocumentPositionParams struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
Position Position `json:"position"`
}
+155
View File
@@ -0,0 +1,155 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"testing"
)
type shortWriter struct{}
func (shortWriter) Write(value []byte) (int, error) {
if len(value) == 0 {
return 0, nil
}
return len(value) - 1, nil
}
func TestProtocolFramingRoundTripAndLimits(t *testing.T) {
t.Parallel()
var output bytes.Buffer
writer := &frameWriter{output: &output}
if err := writer.write(rpcMessage{JSONRPC: "2.0", ID: json.RawMessage("1"), Result: map[string]bool{"ok": true}}); err != nil {
t.Fatal(err)
}
payload, err := newFrameReader(&output).read()
if err != nil {
t.Fatal(err)
}
var decoded rpcMessage
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatal(err)
}
if string(decoded.ID) != "1" {
t.Fatalf("response ID = %s", decoded.ID)
}
invalid := "Content-Length: 999999999\r\n\r\n"
if _, err := newFrameReader(strings.NewReader(invalid)).read(); err == nil {
t.Fatal("excessive frame length was accepted")
}
duplicate := "Content-Length: 2\r\nContent-Length: 2\r\n\r\n{}"
if _, err := newFrameReader(strings.NewReader(duplicate)).read(); err == nil {
t.Fatal("duplicate Content-Length was accepted")
}
var excessive bytes.Buffer
for range maxHeaderLines + 1 {
excessive.WriteString("X-Test: value\r\n")
}
excessive.WriteString("Content-Length: 2\r\n\r\n{}")
if _, err := newFrameReader(&excessive).read(); err == nil {
t.Fatal("excessive header count was accepted")
}
if err := (&frameWriter{output: shortWriter{}}).write(rpcMessage{JSONRPC: "2.0", ID: json.RawMessage("1"), Result: true}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v, want io.ErrShortWrite", err)
}
}
func TestMalformedJSONProducesProtocolErrorAndContinues(t *testing.T) {
t.Parallel()
input := bytes.NewBuffer(nil)
input.WriteString("Content-Length: 1\r\n\r\n{")
exit, err := json.Marshal(rpcMessage{JSONRPC: "2.0", Method: "exit"})
if err != nil {
t.Fatal(err)
}
fmt.Fprintf(input, "Content-Length: %d\r\n\r\n", len(exit))
input.Write(exit)
var output bytes.Buffer
if err := Run(context.Background(), Options{Input: input, Output: &output}); err != nil {
t.Fatal(err)
}
payload, err := newFrameReader(&output).read()
if err != nil {
t.Fatal(err)
}
var response rpcMessage
if err := json.Unmarshal(payload, &response); err != nil {
t.Fatal(err)
}
if response.Error == nil || response.Error.Code != errParse {
t.Fatalf("parse error response = %#v", response)
}
}
func TestRequestIDValidation(t *testing.T) {
t.Parallel()
for _, id := range []string{`1`, `-2`, `"request"`} {
if !validRequestID(json.RawMessage(id)) {
t.Errorf("valid request ID rejected: %s", id)
}
}
for _, id := range []string{`null`, `true`, `1.5`, `{}`, `[]`, `not-json`} {
if validRequestID(json.RawMessage(id)) {
t.Errorf("invalid request ID accepted: %s", id)
}
}
}
func TestUTF16PositionsWithUnicodeAndCRLF(t *testing.T) {
t.Parallel()
text := []byte("a😀b\r\n雪c\n")
tests := []struct {
offset int
position Position
}{
{offset: 0, position: Position{Line: 0, Character: 0}},
{offset: 1, position: Position{Line: 0, Character: 1}},
{offset: 5, position: Position{Line: 0, Character: 3}},
{offset: 8, position: Position{Line: 1, Character: 0}},
{offset: 11, position: Position{Line: 1, Character: 1}},
}
for _, test := range tests {
if got := offsetToPosition(text, test.offset); got != test.position {
t.Errorf("offsetToPosition(%d) = %#v, want %#v", test.offset, got, test.position)
}
if got, ok := positionToOffset(text, test.position); !ok || got != test.offset {
t.Errorf("positionToOffset(%#v) = %d, %v; want %d, true", test.position, got, ok, test.offset)
}
}
if _, ok := positionToOffset(text, Position{Line: 0, Character: 2}); ok {
t.Fatal("position inside UTF-16 surrogate pair was accepted")
}
}
func FuzzFrameReaderNeverPanics(f *testing.F) {
f.Add([]byte("Content-Length: 2\r\n\r\n{}"))
f.Add([]byte("Content-Length: nope\r\n\r\n"))
f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > 64<<10 {
t.Skip()
}
_, _ = newFrameReader(bytes.NewReader(data)).read()
})
}
func FuzzDocumentPositionNeverPanics(f *testing.F) {
f.Add("hello 😀\r\nworld", 0, 7)
f.Add("雪", 0, 1)
f.Fuzz(func(t *testing.T, text string, line, character int) {
if len(text) > 64<<10 || line < -10000 || line > 10000 || character < -10000 || character > 100000 {
t.Skip()
}
offset, ok := positionToOffset([]byte(text), Position{Line: line, Character: character})
if ok {
_ = offsetToPosition([]byte(text), offset)
}
})
}
+64
View File
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestLanguageServerSourceHasNoExecutionNetworkOrWriteCapability(t *testing.T) {
t.Parallel()
entries, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
}
forbiddenImports := map[string]bool{
"net": true, "net/http": true, "net/rpc": true,
"os/exec": true, "syscall": true,
}
forbiddenOSCalls := map[string]bool{
"Create": true, "CreateTemp": true, "Mkdir": true, "MkdirAll": true,
"OpenFile": true, "Remove": true, "RemoveAll": true, "Rename": true,
"WriteFile": true, "Chmod": true, "Chown": true,
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".go" || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
parsed, err := parser.ParseFile(token.NewFileSet(), entry.Name(), nil, 0)
if err != nil {
t.Fatal(err)
}
for _, imported := range parsed.Imports {
path, err := strconv.Unquote(imported.Path.Value)
if err != nil {
t.Fatal(err)
}
if forbiddenImports[path] {
t.Errorf("%s imports forbidden capability %s", entry.Name(), path)
}
}
ast.Inspect(parsed, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
selector, ok := call.Fun.(*ast.SelectorExpr)
if !ok || !forbiddenOSCalls[selector.Sel.Name] {
return true
}
identifier, ok := selector.X.(*ast.Ident)
if ok && identifier.Name == "os" {
t.Errorf("%s calls forbidden filesystem mutation os.%s", entry.Name(), selector.Sel.Name)
}
return true
})
}
}
+371
View File
@@ -0,0 +1,371 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Package lsp implements Hime-san's read-only Language Server Protocol
// adapter. It deliberately owns no generation, Go toolchain, HTTP, network,
// or project-execution behavior.
package lsp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"gamertan.com/sandwich-hime/internal/compiler"
)
// Options configures one stdio language-server process.
type Options struct {
Input io.Reader
Output io.Writer
LogOutput io.Writer
Debounce time.Duration
}
// Server serves exactly one workspace root.
type Server struct {
context context.Context
cancel context.CancelFunc
reader *frameReader
writer *frameWriter
logs io.Writer
mu sync.RWMutex
root string
initialized bool
shutdown bool
overlays map[string]document
snapshot workspaceSnapshot
analysisCancel context.CancelFunc
analysisTimer *time.Timer
analysisGeneration uint64
debounce time.Duration
afterFunc func(time.Duration, func()) *time.Timer
requests map[string]context.CancelFunc
wait sync.WaitGroup
analysisWait sync.WaitGroup
}
// Run serves LSP JSON-RPC until the client sends exit, closes stdin, or the
// parent context is canceled.
func Run(parent context.Context, options Options) error {
if options.Input == nil || options.Output == nil {
return errors.New("LSP stdin and stdout are required")
}
if options.LogOutput == nil {
options.LogOutput = io.Discard
}
if options.Debounce <= 0 {
options.Debounce = 200 * time.Millisecond
}
ctx, cancel := context.WithCancel(parent)
server := &Server{
context: ctx, cancel: cancel,
reader: newFrameReader(options.Input), writer: &frameWriter{output: options.Output}, logs: options.LogOutput,
overlays: make(map[string]document), snapshot: workspaceSnapshot{documents: make(map[string]document), analyses: make(map[string]compiler.DocumentAnalysis)},
debounce: options.Debounce, afterFunc: time.AfterFunc, requests: make(map[string]context.CancelFunc),
}
defer func() {
cancel()
server.mu.Lock()
server.analysisGeneration++
if server.analysisTimer != nil {
server.analysisTimer.Stop()
}
if server.analysisCancel != nil {
server.analysisCancel()
}
for _, requestCancel := range server.requests {
requestCancel()
}
server.mu.Unlock()
server.wait.Wait()
server.analysisWait.Wait()
}()
for {
payload, err := server.reader.read()
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || ctx.Err() != nil {
return nil
}
return fmt.Errorf("read LSP frame: %w", err)
}
var message rpcMessage
if err := json.Unmarshal(payload, &message); err != nil {
_ = server.writer.write(rpcMessage{JSONRPC: "2.0", ID: json.RawMessage("null"), Error: &rpcError{Code: errParse, Message: "invalid JSON"}})
continue
}
if message.JSONRPC != "2.0" || message.Method == "" || !validRequestID(message.ID) {
_ = server.writer.write(rpcMessage{JSONRPC: "2.0", ID: responseID(message.ID), Error: &rpcError{Code: errInvalidRequest, Message: "invalid JSON-RPC request"}})
continue
}
if len(message.ID) == 0 {
if message.Method == "exit" {
server.cancel()
return nil
}
server.handleNotification(message.Method, message.Params)
continue
}
server.startRequest(message)
}
}
func responseID(id json.RawMessage) json.RawMessage {
if len(id) == 0 {
return json.RawMessage("null")
}
return append(json.RawMessage(nil), id...)
}
func (server *Server) startRequest(message rpcMessage) {
key := string(message.ID)
ctx, cancel := context.WithCancel(server.context)
server.mu.Lock()
server.requests[key] = cancel
server.mu.Unlock()
server.wait.Add(1)
go func() {
defer server.wait.Done()
defer cancel()
result, rpcErr := server.handleRequest(ctx, message.Method, message.Params)
if ctx.Err() != nil && rpcErr == nil {
rpcErr = &rpcError{Code: errRequestCancelled, Message: "request canceled"}
}
if rpcErr == nil && result == nil {
result = json.RawMessage("null")
}
server.mu.Lock()
delete(server.requests, key)
server.mu.Unlock()
_ = server.writer.write(rpcMessage{JSONRPC: "2.0", ID: responseID(message.ID), Result: result, Error: rpcErr})
}()
}
func (server *Server) handleRequest(ctx context.Context, method string, params json.RawMessage) (any, *rpcError) {
switch method {
case "initialize":
return server.initialize(params)
case "shutdown":
server.mu.Lock()
server.shutdown = true
if server.analysisCancel != nil {
server.analysisCancel()
}
server.mu.Unlock()
return nil, nil
}
server.mu.RLock()
ready := server.initialized && !server.shutdown
server.mu.RUnlock()
if !ready {
return nil, &rpcError{Code: errInvalidRequest, Message: "language server is not initialized"}
}
select {
case <-ctx.Done():
return nil, &rpcError{Code: errRequestCancelled, Message: "request canceled"}
default:
}
switch method {
case "textDocument/completion":
var request textDocumentPositionParams
if err := json.Unmarshal(params, &request); err != nil {
return nil, &rpcError{Code: errInvalidParams, Message: "invalid completion parameters"}
}
return server.completions(request), nil
case "textDocument/hover":
var request textDocumentPositionParams
if err := json.Unmarshal(params, &request); err != nil {
return nil, &rpcError{Code: errInvalidParams, Message: "invalid hover parameters"}
}
return server.hover(request), nil
case "textDocument/definition":
var request textDocumentPositionParams
if err := json.Unmarshal(params, &request); err != nil {
return nil, &rpcError{Code: errInvalidParams, Message: "invalid definition parameters"}
}
return server.definition(request), nil
case "textDocument/documentSymbol":
var request struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
}
if err := json.Unmarshal(params, &request); err != nil {
return nil, &rpcError{Code: errInvalidParams, Message: "invalid document-symbol parameters"}
}
return server.documentSymbols(request.TextDocument.URI), nil
default:
return nil, &rpcError{Code: errMethodNotFound, Message: "method not supported"}
}
}
func (server *Server) initialize(params json.RawMessage) (any, *rpcError) {
var request struct {
RootURI string `json:"rootUri"`
RootPath string `json:"rootPath"`
WorkspaceFolders []struct {
URI string `json:"uri"`
} `json:"workspaceFolders"`
}
if err := json.Unmarshal(params, &request); err != nil {
return nil, &rpcError{Code: errInvalidParams, Message: "invalid initialize parameters"}
}
if len(request.WorkspaceFolders) > 1 {
return nil, &rpcError{Code: errInvalidParams, Message: "Hime-san accepts one workspace folder per language-server process"}
}
rootURI := request.RootURI
if len(request.WorkspaceFolders) == 1 {
rootURI = request.WorkspaceFolders[0].URI
}
var root string
var err error
if rootURI != "" {
root, err = fileURIToPath(rootURI)
} else if request.RootPath != "" {
root, err = filepath.Abs(request.RootPath)
} else {
root, err = os.Getwd()
}
if err != nil {
return nil, &rpcError{Code: errInvalidParams, Message: "workspace root is not a local filesystem path"}
}
info, err := os.Lstat(root)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, &rpcError{Code: errInvalidParams, Message: "workspace root must be an existing non-symlink directory"}
}
evaluated, err := filepath.EvalSymlinks(root)
if err != nil || filepath.Clean(evaluated) != filepath.Clean(root) {
return nil, &rpcError{Code: errInvalidParams, Message: "workspace roots reached through symlinks are not supported"}
}
server.mu.Lock()
if server.initialized {
server.mu.Unlock()
return nil, &rpcError{Code: errInvalidRequest, Message: "initialize may be sent only once"}
}
server.root = filepath.Clean(root)
server.initialized = true
server.mu.Unlock()
return struct {
Capabilities any `json:"capabilities"`
ServerInfo any `json:"serverInfo"`
}{
Capabilities: map[string]any{
"positionEncoding": "utf-16",
"textDocumentSync": map[string]any{"openClose": true, "change": 1, "save": map[string]any{"includeText": true}},
"completionProvider": map[string]any{"triggerCharacters": []string{"<", "?", "~", "."}, "resolveProvider": false},
"hoverProvider": true, "definitionProvider": true, "documentSymbolProvider": true,
"workspace": map[string]any{"workspaceFolders": map[string]any{"supported": false, "changeNotifications": false}},
},
ServerInfo: map[string]any{"name": "himesan", "version": compiler.CompilerVersion},
}, nil
}
func (server *Server) handleNotification(method string, params json.RawMessage) {
switch method {
case "initialized":
server.scheduleReindex(false)
case "$/cancelRequest":
var request struct {
ID json.RawMessage `json:"id"`
}
if json.Unmarshal(params, &request) == nil {
server.mu.RLock()
cancel := server.requests[string(request.ID)]
server.mu.RUnlock()
if cancel != nil {
cancel()
}
}
case "textDocument/didOpen":
var request struct {
TextDocument struct {
URI string `json:"uri"`
Version int `json:"version"`
Text string `json:"text"`
} `json:"textDocument"`
}
if json.Unmarshal(params, &request) == nil {
server.updateOverlay(request.TextDocument.URI, request.TextDocument.Version, request.TextDocument.Text, true)
server.scheduleReindex(false)
}
case "textDocument/didChange":
var request struct {
TextDocument versionedTextDocumentIdentifier `json:"textDocument"`
ContentChanges []struct {
Range *Range `json:"range,omitempty"`
Text string `json:"text"`
} `json:"contentChanges"`
}
if json.Unmarshal(params, &request) == nil && len(request.ContentChanges) != 0 {
change := request.ContentChanges[len(request.ContentChanges)-1]
if change.Range == nil {
server.updateOverlay(request.TextDocument.URI, request.TextDocument.Version, change.Text, true)
server.scheduleReindex(true)
}
}
case "textDocument/didSave":
var request struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
Text *string `json:"text,omitempty"`
}
if json.Unmarshal(params, &request) == nil {
if request.Text != nil {
server.updateOverlay(request.TextDocument.URI, -1, *request.Text, true)
}
server.scheduleReindex(false)
}
case "textDocument/didClose":
var request struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
}
if json.Unmarshal(params, &request) == nil {
if path, err := fileURIToPath(request.TextDocument.URI); err == nil {
server.mu.Lock()
delete(server.overlays, path)
server.mu.Unlock()
server.scheduleReindex(false)
}
}
case "workspace/didChangeWatchedFiles":
server.scheduleReindex(false)
}
}
func (server *Server) updateOverlay(uri string, version int, text string, open bool) {
if len(text) > maxDocumentBytes || !strings.HasSuffix(strings.ToLower(uri), ".sando") {
return
}
path, err := fileURIToPath(uri)
if err != nil {
return
}
server.mu.Lock()
defer server.mu.Unlock()
if !editorPathAllowed(server.root, path) {
return
}
if previous, ok := server.overlays[path]; ok && version < 0 {
version = previous.Version
}
server.overlays[path] = document{URI: uri, Path: path, Text: []byte(text), Version: version, Open: open}
}
func (server *Server) notify(method string, params any) error {
payload, err := json.Marshal(params)
if err != nil {
return err
}
return server.writer.write(rpcMessage{JSONRPC: "2.0", Method: method, Params: payload})
}
func (server *Server) log(message string, count int) {
// Logs deliberately contain only fixed messages and counts. Source text,
// paths, environment values, and process details never cross this boundary.
fmt.Fprintf(server.logs, "himesan lsp: %s (%d)\n", message, count)
}
+317
View File
@@ -0,0 +1,317 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"bytes"
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"gamertan.com/sandwich-hime/internal/compiler"
)
type protocolClient struct {
input *io.PipeWriter
output *frameReader
nextID int
lastPayload []byte
}
func newProtocolClient(t *testing.T, root string) (*protocolClient, <-chan error) {
t.Helper()
serverInput, clientInput := io.Pipe()
clientOutput, serverOutput := io.Pipe()
done := make(chan error, 1)
go func() {
done <- Run(context.Background(), Options{Input: serverInput, Output: serverOutput, LogOutput: io.Discard, Debounce: 10 * time.Millisecond})
_ = serverOutput.Close()
}()
client := &protocolClient{input: clientInput, output: newFrameReader(clientOutput)}
response := client.call(t, "initialize", map[string]any{"rootUri": pathToURI(root)})
if response.Error != nil {
t.Fatalf("initialize: %#v", response.Error)
}
client.notify(t, "initialized", map[string]any{})
return client, done
}
func (client *protocolClient) send(t *testing.T, message rpcMessage) {
t.Helper()
payload, err := json.Marshal(message)
if err != nil {
t.Fatal(err)
}
frame := append([]byte("Content-Length: "+itoa(len(payload))+"\r\n\r\n"), payload...)
if _, err := client.input.Write(frame); err != nil {
t.Fatal(err)
}
}
func (client *protocolClient) call(t *testing.T, method string, params any) rpcMessage {
t.Helper()
client.nextID++
id := client.nextID
payload, err := json.Marshal(params)
if err != nil {
t.Fatal(err)
}
client.send(t, rpcMessage{JSONRPC: "2.0", ID: json.RawMessage(itoa(id)), Method: method, Params: payload})
for {
message := client.read(t)
if string(message.ID) == itoa(id) {
return message
}
}
}
func (client *protocolClient) notify(t *testing.T, method string, params any) {
t.Helper()
payload, err := json.Marshal(params)
if err != nil {
t.Fatal(err)
}
client.send(t, rpcMessage{JSONRPC: "2.0", Method: method, Params: payload})
}
func (client *protocolClient) read(t *testing.T) rpcMessage {
t.Helper()
type result struct {
message rpcMessage
payload []byte
err error
}
ready := make(chan result, 1)
go func() {
payload, err := client.output.read()
if err != nil {
ready <- result{err: err}
return
}
var message rpcMessage
err = json.Unmarshal(payload, &message)
ready <- result{message: message, payload: payload, err: err}
}()
select {
case got := <-ready:
if got.err != nil {
t.Fatal(got.err)
}
client.lastPayload = got.payload
return got.message
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for language-server response")
return rpcMessage{}
}
}
func (client *protocolClient) waitDiagnostics(t *testing.T, uri string, wantCode string) []lspDiagnostic {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
message := client.read(t)
if message.Method != "textDocument/publishDiagnostics" {
continue
}
var published struct {
URI string `json:"uri"`
Diagnostics []lspDiagnostic `json:"diagnostics"`
}
if json.Unmarshal(message.Params, &published) != nil || published.URI != uri {
continue
}
if wantCode == "" {
return published.Diagnostics
}
for _, item := range published.Diagnostics {
if item.Code == wantCode {
return published.Diagnostics
}
}
}
t.Fatalf("timed out waiting for %s diagnostic", wantCode)
return nil
}
func TestServerOverlayFeaturesAndNoWrites(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "go.mod"), "module example.test/project\n\ngo 1.25\n")
homePath := filepath.Join(root, "home.sando")
badgePath := filepath.Join(root, "cards", "badge.sando")
writeTestFile(t, homePath, "<?sando go\npackage views\nfunc Home(visitor string)\n?>\n<p><?= visitor ?></p>\n")
writeTestFile(t, badgePath, "<?sando go\npackage cards\nfunc Badge(label string)\n?>\n<strong><?= label ?></strong>\n")
client, done := newProtocolClient(t, root)
homeURI := pathToURI(homePath)
client.waitDiagnostics(t, homeURI, "")
overlay := "<?sando go\npackage views\nimport \"example.test/project/cards\"\nfunc Home(visitor string)\n?>\n<p>😀 <?= visitor ?></p>\n<?~ cards.Badge(\"new\") ?>\n"
client.notify(t, "textDocument/didOpen", map[string]any{"textDocument": map[string]any{"uri": homeURI, "languageId": "sando", "version": 1, "text": overlay}})
if diagnostics := client.waitDiagnostics(t, homeURI, ""); len(diagnostics) != 0 {
t.Fatalf("valid overlay diagnostics = %#v", diagnostics)
}
completionOffset := strings.Index(overlay, "cards.Badge") + len("cards.B")
completion := client.call(t, "textDocument/completion", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), completionOffset)})
assertJSONContains(t, completion.Result, `"label":"cards.Badge"`)
definitionOffset := strings.Index(overlay, "Badge") + 2
definition := client.call(t, "textDocument/definition", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), definitionOffset)})
assertJSONContains(t, definition.Result, pathToURI(badgePath))
hoverOffset := strings.Index(overlay, "visitor ?></p>") + 2
hover := client.call(t, "textDocument/hover", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), hoverOffset)})
assertJSONContains(t, hover.Result, "html-text")
symbols := client.call(t, "textDocument/documentSymbol", map[string]any{"textDocument": map[string]string{"uri": homeURI}})
assertJSONContains(t, symbols.Result, `"name":"Home"`)
if err := os.Remove(badgePath); err != nil {
t.Fatal(err)
}
client.notify(t, "workspace/didChangeWatchedFiles", map[string]any{"changes": []map[string]any{{"uri": pathToURI(badgePath), "type": 3}}})
client.waitDiagnostics(t, pathToURI(badgePath), "")
completion = client.call(t, "textDocument/completion", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), completionOffset)})
if payload, _ := json.Marshal(completion.Result); strings.Contains(string(payload), `"label":"cards.Badge"`) {
t.Fatalf("deleted component remained in completion index: %s", payload)
}
broken := strings.Replace(overlay, "<?~ cards.Badge(\"new\") ?>", "<div>", 1)
client.notify(t, "textDocument/didChange", map[string]any{
"textDocument": map[string]any{"uri": homeURI, "version": 2},
"contentChanges": []map[string]string{{"text": broken}},
})
client.waitDiagnostics(t, homeURI, "HIM1311")
if _, err := os.Stat(homePath + ".go"); !os.IsNotExist(err) {
t.Fatalf("language server wrote generated output: %v", err)
}
shutdown := client.call(t, "shutdown", map[string]any{})
if shutdown.Error != nil {
t.Fatalf("shutdown: %#v", shutdown.Error)
}
if !bytes.Contains(client.lastPayload, []byte(`"result":null`)) {
t.Fatalf("shutdown response omitted JSON-RPC null result: %s", client.lastPayload)
}
client.notify(t, "exit", map[string]any{})
_ = client.input.Close()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("language server did not exit")
}
}
func TestOverlayHonorsNestedModuleAndSymlinkBoundaries(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "go.mod"), "module example.test/root\n")
nestedPath := filepath.Join(root, "nested", "view.sando")
writeTestFile(t, filepath.Join(root, "nested", "go.mod"), "module example.test/nested\n")
writeTestFile(t, nestedPath, "<?sando go\npackage nested\nfunc View()\n?>\n<p>no</p>\n")
server := &Server{root: root, overlays: make(map[string]document)}
server.updateOverlay(pathToURI(nestedPath), 1, "ignored", true)
if len(server.overlays) != 0 {
t.Fatalf("nested-module overlay was accepted: %#v", server.overlays)
}
if runtime.GOOS != "windows" {
realDirectory := filepath.Join(root, "real")
if err := os.Mkdir(realDirectory, 0o700); err != nil {
t.Fatal(err)
}
linkDirectory := filepath.Join(root, "linked")
if err := os.Symlink(realDirectory, linkDirectory); err != nil {
t.Fatal(err)
}
linkedPath := filepath.Join(linkDirectory, "view.sando")
server.updateOverlay(pathToURI(linkedPath), 1, "ignored", true)
if len(server.overlays) != 0 {
t.Fatalf("symlink overlay was accepted: %#v", server.overlays)
}
}
}
func TestServerRejectsMultipleRootsAndCanceledRequest(t *testing.T) {
root := t.TempDir()
server := &Server{initialized: true, snapshot: workspaceSnapshot{documents: map[string]document{}, analyses: map[string]compiler.DocumentAnalysis{}}}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, rpcErr := server.handleRequest(ctx, "textDocument/completion", json.RawMessage(`{}`))
if rpcErr == nil || rpcErr.Code != errRequestCancelled {
t.Fatalf("canceled request error = %#v", rpcErr)
}
input, writer := io.Pipe()
reader, serverOutput := io.Pipe()
done := make(chan error, 1)
go func() { done <- Run(context.Background(), Options{Input: input, Output: serverOutput}) }()
client := &protocolClient{input: writer, output: newFrameReader(reader)}
response := client.call(t, "initialize", map[string]any{"workspaceFolders": []map[string]string{{"uri": pathToURI(root)}, {"uri": pathToURI(root)}}})
if response.Error == nil || response.Error.Code != errInvalidParams {
t.Fatalf("multiple-root response = %#v", response)
}
client.notify(t, "exit", map[string]any{})
_ = writer.Close()
<-done
}
func TestReindexCountsOpenOverlaysInWorkspaceLimit(t *testing.T) {
root := t.TempDir()
server := &Server{
root: root,
overlays: make(map[string]document, maxWorkspaceFiles+1),
snapshot: workspaceSnapshot{documents: map[string]document{}, analyses: map[string]compiler.DocumentAnalysis{}},
writer: &frameWriter{output: io.Discard},
logs: io.Discard,
}
for index := range maxWorkspaceFiles + 1 {
path := filepath.Join(root, "overlay-"+itoa(index)+".sando")
server.overlays[path] = document{URI: pathToURI(path), Path: path, Text: []byte("<?sando go\npackage views\nfunc View()\n?>\n"), Open: true}
}
err := server.reindex(context.Background())
if err == nil || !strings.Contains(err.Error(), "more than 10000") {
t.Fatalf("reindex overlay limit error = %v", err)
}
}
func writeTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
func assertJSONContains(t *testing.T, value any, marker string) {
t.Helper()
payload, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(payload), marker) {
t.Fatalf("JSON %s does not contain %q", payload, marker)
}
}
func itoa(value int) string {
if value == 0 {
return "0"
}
var digits [20]byte
index := len(digits)
for value > 0 {
index--
digits[index] = byte('0' + value%10)
value /= 10
}
return string(digits[index:])
}
+466
View File
@@ -0,0 +1,466 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"unicode/utf16"
"unicode/utf8"
"gamertan.com/sandwich-hime/internal/compiler"
)
const (
maxDocumentBytes = 16 << 20
maxWorkspaceBytes = 64 << 20
maxWorkspaceFiles = 10000
)
type document struct {
URI string
Path string
Text []byte
Version int
Open bool
}
type workspaceSnapshot struct {
documents map[string]document
analyses map[string]compiler.DocumentAnalysis
moduleRoot string
modulePath string
}
func (server *Server) reindex(ctx context.Context) error {
server.mu.RLock()
root := server.root
overlays := make(map[string]document, len(server.overlays))
for path, item := range server.overlays {
overlays[path] = item
}
server.mu.RUnlock()
if root == "" {
return nil
}
paths, discoveryDiagnostics := compiler.DiscoverSources(ctx, []string{root})
if err := ctx.Err(); err != nil {
return err
}
if len(paths) > maxWorkspaceFiles {
return fmt.Errorf("workspace contains more than %d .sando files", maxWorkspaceFiles)
}
documents := make(map[string]document, len(paths)+len(overlays))
total := 0
for _, path := range paths {
absolute, err := filepath.Abs(path)
if err != nil {
continue
}
absolute = filepath.Clean(absolute)
if overlay, ok := overlays[absolute]; ok {
documents[absolute] = overlay
total += len(overlay.Text)
continue
}
info, err := os.Lstat(absolute)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() > maxDocumentBytes {
continue
}
content, err := os.ReadFile(absolute)
if err != nil || len(content) > maxDocumentBytes {
continue
}
documents[absolute] = document{URI: pathToURI(absolute), Path: absolute, Text: content}
total += len(content)
if total > maxWorkspaceBytes {
return fmt.Errorf("workspace .sando sources exceed %d bytes", maxWorkspaceBytes)
}
}
for path, overlay := range overlays {
if _, ok := documents[path]; ok {
continue
}
if overlay.Open && editorPathAllowed(root, path) {
documents[path] = overlay
total += len(overlay.Text)
}
}
if len(documents) > maxWorkspaceFiles {
return fmt.Errorf("workspace contains more than %d .sando files", maxWorkspaceFiles)
}
if total > maxWorkspaceBytes {
return fmt.Errorf("workspace .sando sources exceed %d bytes", maxWorkspaceBytes)
}
inputs := make([]compiler.SourceInput, 0, len(documents))
for _, item := range documents {
inputs = append(inputs, compiler.SourceInput{Path: item.Path, Source: item.Text})
}
analysed := compiler.AnalyzeSources(ctx, inputs)
if err := ctx.Err(); err != nil {
return err
}
analyses := make(map[string]compiler.DocumentAnalysis, len(analysed))
for _, item := range analysed {
analyses[filepath.Clean(item.Path)] = item
}
moduleRoot, modulePath := moduleIdentity(root)
server.mu.Lock()
previous := server.snapshot.documents
server.snapshot = workspaceSnapshot{documents: documents, analyses: analyses, moduleRoot: moduleRoot, modulePath: modulePath}
server.mu.Unlock()
all := make(map[string]bool, len(previous)+len(documents))
for path := range previous {
all[path] = true
}
for path := range documents {
all[path] = true
}
ordered := make([]string, 0, len(all))
for path := range all {
ordered = append(ordered, path)
}
sort.Strings(ordered)
for _, path := range ordered {
item, exists := documents[path]
uri := pathToURI(path)
diagnostics := make([]lspDiagnostic, 0)
if exists {
uri = item.URI
for _, diagnostic := range analyses[path].Diagnostics {
diagnostics = append(diagnostics, diagnosticToLSP(item.Text, diagnostic))
}
}
if err := server.notify("textDocument/publishDiagnostics", struct {
URI string `json:"uri"`
Diagnostics []lspDiagnostic `json:"diagnostics"`
}{URI: uri, Diagnostics: diagnostics}); err != nil {
return err
}
}
if len(discoveryDiagnostics) != 0 {
server.log("workspace discovery reported boundary diagnostics", len(discoveryDiagnostics))
}
server.log("analysis completed", len(documents))
return nil
}
func (server *Server) scheduleReindex(delay bool) {
server.mu.Lock()
if server.analysisCancel != nil {
server.analysisCancel()
}
if server.analysisTimer != nil {
server.analysisTimer.Stop()
}
generation := server.analysisGeneration + 1
server.analysisGeneration = generation
wait := server.debounce
if !delay {
wait = 0
}
server.analysisTimer = server.afterFunc(wait, func() {
ctx, cancel := context.WithCancel(server.context)
server.mu.Lock()
if server.analysisGeneration != generation {
server.mu.Unlock()
cancel()
return
}
server.analysisWait.Add(1)
server.analysisCancel = cancel
server.mu.Unlock()
defer server.analysisWait.Done()
err := server.reindex(ctx)
cancel()
server.mu.Lock()
if server.analysisGeneration == generation {
server.analysisCancel = nil
}
server.mu.Unlock()
if err != nil && !errors.Is(err, context.Canceled) {
server.log("analysis failed", 1)
}
})
server.mu.Unlock()
}
func (server *Server) snapshotDocument(uri string) (document, compiler.DocumentAnalysis, bool) {
path, err := fileURIToPath(uri)
if err != nil {
return document{}, compiler.DocumentAnalysis{}, false
}
server.mu.RLock()
defer server.mu.RUnlock()
item, ok := server.snapshot.documents[path]
if !ok {
return document{}, compiler.DocumentAnalysis{}, false
}
return item, server.snapshot.analyses[path], true
}
func moduleIdentity(root string) (string, string) {
candidate := filepath.Join(root, "go.mod")
content, err := os.ReadFile(candidate)
if err != nil || len(content) > 1<<20 {
return "", ""
}
for _, line := range strings.Split(string(content), "\n") {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) == 2 && fields[0] == "module" {
return filepath.Clean(root), fields[1]
}
}
return "", ""
}
func (snapshot workspaceSnapshot) packageImportPath(directory string) string {
if snapshot.moduleRoot == "" || snapshot.modulePath == "" || !withinRoot(snapshot.moduleRoot, directory) {
return ""
}
relative, err := filepath.Rel(snapshot.moduleRoot, directory)
if err != nil || relative == "." {
return snapshot.modulePath
}
return strings.TrimSuffix(snapshot.modulePath, "/") + "/" + filepath.ToSlash(relative)
}
func (snapshot workspaceSnapshot) componentsFor(path string, analysis compiler.DocumentAnalysis) []componentTarget {
directory := filepath.Dir(path)
var targets []componentTarget
for targetPath, targetAnalysis := range snapshot.analyses {
if targetAnalysis.Component == "" {
continue
}
targetDirectory := filepath.Dir(targetPath)
if targetDirectory == directory && targetAnalysis.Package == analysis.Package {
targets = append(targets, componentTarget{Name: targetAnalysis.Component, Signature: targetAnalysis.Signature, Path: targetPath, Analysis: targetAnalysis})
continue
}
importPath := snapshot.packageImportPath(targetDirectory)
for _, imported := range analysis.Imports {
if imported.Path != importPath || imported.Alias == "_" || imported.Alias == "." {
continue
}
alias := imported.Alias
if alias == "" {
alias = targetAnalysis.Package
}
targets = append(targets, componentTarget{Qualifier: alias, Name: targetAnalysis.Component, Signature: targetAnalysis.Signature, Path: targetPath, Analysis: targetAnalysis})
}
}
sort.SliceStable(targets, func(i, j int) bool {
return targets[i].Label() < targets[j].Label()
})
return targets
}
type componentTarget struct {
Qualifier string
Name string
Signature string
Path string
Analysis compiler.DocumentAnalysis
}
func (target componentTarget) Label() string {
if target.Qualifier == "" {
return target.Name
}
return target.Qualifier + "." + target.Name
}
func withinRoot(root, path string) bool {
relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
}
func editorPathAllowed(root, path string) bool {
if filepath.Ext(path) != ".sando" || !withinRoot(root, path) {
return false
}
relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
if err != nil {
return false
}
current := filepath.Clean(root)
parts := strings.Split(relative, string(filepath.Separator))
for index, part := range parts {
if part == "" || part == "." {
continue
}
if index < len(parts)-1 && (part == ".git" || part == ".hg" || part == ".svn" || part == "vendor") {
return false
}
current = filepath.Join(current, part)
info, statErr := os.Lstat(current)
if statErr == nil && info.Mode()&os.ModeSymlink != 0 {
return false
}
if statErr != nil && !os.IsNotExist(statErr) {
return false
}
if index < len(parts)-1 && current != filepath.Clean(root) {
moduleInfo, moduleErr := os.Lstat(filepath.Join(current, "go.mod"))
if moduleErr == nil || moduleInfo != nil {
return false
}
if moduleErr != nil && !os.IsNotExist(moduleErr) {
return false
}
}
}
return true
}
func fileURIToPath(value string) (string, error) {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "file" || (parsed.Host != "" && parsed.Host != "localhost") {
return "", errors.New("only local file URIs are supported")
}
path, err := url.PathUnescape(parsed.EscapedPath())
if err != nil {
return "", err
}
if runtime.GOOS == "windows" && len(path) >= 3 && path[0] == '/' && path[2] == ':' {
path = path[1:]
}
absolute, err := filepath.Abs(filepath.FromSlash(path))
if err != nil {
return "", err
}
return filepath.Clean(absolute), nil
}
func pathToURI(path string) string {
absolute, err := filepath.Abs(path)
if err != nil {
absolute = filepath.Clean(path)
}
slashed := filepath.ToSlash(absolute)
if runtime.GOOS == "windows" && !strings.HasPrefix(slashed, "/") {
slashed = "/" + slashed
}
return (&url.URL{Scheme: "file", Path: slashed}).String()
}
func offsetToPosition(text []byte, offset int) Position {
if offset < 0 {
offset = 0
}
if offset > len(text) {
offset = len(text)
}
line, character := 0, 0
for index := 0; index < offset; {
if text[index] == '\n' {
line++
character = 0
index++
continue
}
r, size := utf8.DecodeRune(text[index:])
if r == utf8.RuneError && size == 1 {
character++
index++
continue
}
character += len(utf16.Encode([]rune{r}))
index += size
}
return Position{Line: line, Character: character}
}
func positionToOffset(text []byte, position Position) (int, bool) {
if position.Line < 0 || position.Character < 0 {
return 0, false
}
line := 0
start := 0
for start < len(text) && line < position.Line {
if text[start] == '\n' {
line++
}
start++
}
if line != position.Line {
return 0, false
}
units := 0
for index := start; index < len(text) && text[index] != '\n'; {
if units == position.Character {
return index, true
}
r, size := utf8.DecodeRune(text[index:])
if r == utf8.RuneError && size == 1 {
units++
index++
} else {
units += len(utf16.Encode([]rune{r}))
index += size
}
if units > position.Character {
return 0, false
}
}
if units == position.Character {
index := start
for index < len(text) && text[index] != '\n' {
index++
}
return index, true
}
return 0, false
}
func compilerPositionOffset(text []byte, line, column int) int {
if line < 1 {
line = 1
}
if column < 1 {
column = 1
}
start := 0
for current := 1; current < line && start < len(text); current++ {
newline := strings.IndexByte(string(text[start:]), '\n')
if newline < 0 {
return len(text)
}
start += newline + 1
}
offset := start + column - 1
if offset > len(text) {
offset = len(text)
}
return offset
}
func diagnosticToLSP(text []byte, diagnostic compiler.Diagnostic) lspDiagnostic {
startOffset := compilerPositionOffset(text, diagnostic.Line, diagnostic.Column)
endOffset := startOffset
if endOffset < len(text) {
_, size := utf8.DecodeRune(text[endOffset:])
if size < 1 {
size = 1
}
endOffset += size
}
severity := 1
if diagnostic.Severity == compiler.SeverityWarning {
severity = 2
}
return lspDiagnostic{
Range: Range{Start: offsetToPosition(text, startOffset), End: offsetToPosition(text, endOffset)},
Severity: severity, Code: diagnostic.Code, Source: "himesan", Message: diagnostic.Message,
}
}
+2
View File
@@ -17,6 +17,7 @@ func TestSelectCompilerVersion(t *testing.T) {
{name: "missing build info", linkerValue: developmentCompilerVersion, moduleVersion: "", want: developmentCompilerVersion},
{name: "versioned go install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0", want: "v1.0.0"},
{name: "beta launch install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta.1", want: "v1.0.0-beta.1"},
{name: "beta two install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta.2", want: "v1.0.0-beta.2"},
{name: "versioned prerelease install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-rc.1", want: "v1.0.0-rc.1"},
{name: "hyphenated prerelease install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.2.3-beta-2", want: "v1.2.3-beta-2"},
{name: "pseudo version", linkerValue: developmentCompilerVersion, moduleVersion: "v0.0.0-20260811120000-0123456789ab", want: developmentCompilerVersion},
@@ -30,6 +31,7 @@ func TestSelectCompilerVersion(t *testing.T) {
{name: "leading zero numeric beta identifier", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta.01", want: developmentCompilerVersion},
{name: "empty beta identifier", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta..1", want: developmentCompilerVersion},
{name: "beta linker override wins", linkerValue: "v1.0.0-beta.1", moduleVersion: "(devel)", want: "v1.0.0-beta.1"},
{name: "beta two linker override wins", linkerValue: "v1.0.0-beta.2", moduleVersion: "(devel)", want: "v1.0.0-beta.2"},
{name: "linker override wins", linkerValue: "v1.0.0-rc.1", moduleVersion: "v1.0.0", want: "v1.0.0-rc.1"},
}
for _, test := range tests {
+68 -6
View File
@@ -8,13 +8,14 @@ cd "$repo_root"
usage() {
cat <<'EOF'
Usage: scripts/release-check.sh --version vX.Y.Z [--public]
Usage: scripts/release-check.sh --version vX.Y.Z [--runtime-tag sando/vX.Y.Z] [--public]
Runs a read-only release preflight. It never creates tags, commits, release
artifacts in the repository, pushes, or deploys.
--version Candidate compiler version. The corresponding runtime tag is
sando/vX.Y.Z.
--version Candidate compiler version.
--runtime-tag Existing runtime tag retained by a compiler-only release.
Omit only when publishing a matching new runtime tag.
--public Require the human-reviewed RC/final launch evidence bundle named
by HIMESAN_RELEASE_EVIDENCE_DIR. Canonical beta prereleases may
run their narrower publication preflight without this flag.
@@ -22,6 +23,7 @@ EOF
}
version=''
runtime_tag=''
public_release=0
while (( $# > 0 )); do
case "$1" in
@@ -30,6 +32,11 @@ while (( $# > 0 )); do
version=$2
shift 2
;;
--runtime-tag)
[[ $# -ge 2 ]] || { usage >&2; exit 2; }
runtime_tag=$2
shift 2
;;
--public)
public_release=1
shift
@@ -74,7 +81,32 @@ if (( public_release == 0 && beta_release == 0 )); then
exit 2
fi
runtime_tag="sando/$version"
paired_runtime_tag="sando/$version"
compiler_only=0
if [[ -z "$runtime_tag" ]]; then
runtime_tag=$paired_runtime_tag
elif [[ ! "$runtime_tag" =~ ^sando/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$ ]]; then
printf 'error: --runtime-tag must be a canonical sando/v semantic version without build metadata\n' >&2
exit 2
else
runtime_prerelease=${BASH_REMATCH[5]:-}
if [[ "$runtime_prerelease" =~ (^|[.-])(0\.)?[0-9]{14}-[0-9a-f]{12,}$ ]]; then
printf 'error: --runtime-tag must identify a signed release, not a Go pseudo-version\n' >&2
exit 2
fi
if [[ -n "$runtime_prerelease" ]]; then
IFS=. read -r -a runtime_prerelease_identifiers <<<"$runtime_prerelease"
for identifier in "${runtime_prerelease_identifiers[@]}"; do
if [[ "$identifier" =~ ^[0-9]+$ && "$identifier" =~ ^0[0-9]+$ ]]; then
printf 'error: runtime numeric prerelease identifiers must not contain leading zeroes: %s\n' "$identifier" >&2
exit 2
fi
done
fi
if [[ "$runtime_tag" != "$paired_runtime_tag" ]]; then
compiler_only=1
fi
fi
if [[ -n "$(git status --porcelain=v1 --untracked-files=all)" ]]; then
printf 'error: release preflight requires a clean canonical checkout\n' >&2
@@ -99,7 +131,11 @@ if [[ "$branch" != main ]]; then
exit 1
fi
for tag in "$version" "$runtime_tag"; do
candidate_tags=("$version")
if (( compiler_only == 0 )); then
candidate_tags+=("$runtime_tag")
fi
for tag in "${candidate_tags[@]}"; do
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
printf 'error: candidate tag already exists locally: %s\n' "$tag" >&2
exit 1
@@ -114,6 +150,26 @@ for tag in "$version" "$runtime_tag"; do
fi
done
if (( compiler_only == 1 )); then
if ! runtime_refs=$(git ls-remote --tags origin "refs/tags/$runtime_tag" "refs/tags/$runtime_tag^{}" 2>/dev/null); then
printf 'error: could not verify retained runtime tag against canonical origin: %s\n' "$runtime_tag" >&2
exit 1
fi
runtime_commit=$(printf '%s\n' "$runtime_refs" | awk '$2 ~ /\^\{\}$/ {print $1}')
if [[ -z "$runtime_commit" || ! "$runtime_commit" =~ ^[0-9a-f]{40}$ ]]; then
printf 'error: retained runtime tag is absent or is not annotated: %s\n' "$runtime_tag" >&2
exit 1
fi
if ! git cat-file -e "$runtime_commit^{commit}" 2>/dev/null; then
printf 'error: retained runtime tag commit is not present in canonical history: %s\n' "$runtime_tag" >&2
exit 1
fi
if [[ "$(git rev-parse HEAD:sando)" != "$(git rev-parse "$runtime_commit:sando")" ]]; then
printf 'error: compiler-only release changed the sando subtree retained at %s\n' "$runtime_tag" >&2
exit 1
fi
fi
artifact_dir=$(mktemp -d "${TMPDIR:-/tmp}/himesan-release-check.XXXXXXXX")
cleanup() {
if [[ -n "${artifact_dir:-}" && -d "$artifact_dir" ]]; then
@@ -135,7 +191,7 @@ if [[ "$actual_human_version" != "$expected_human_version" ]]; then
"$expected_human_version" "$actual_human_version" >&2
exit 1
fi
expected_json_version=$(printf '{"compiler":"%s","runtime_abi":"sando.v1","go":"%s"}' "$version" "$candidate_go_version")
expected_json_version=$(printf '{"compiler":"%s","runtime_abi":"sando.v1","go":"%s","features":["lsp-stdio"]}' "$version" "$candidate_go_version")
actual_json_version=$("$candidate_binary" version --json)
if [[ "$actual_json_version" != "$expected_json_version" ]]; then
printf 'error: candidate JSON version mismatch\nexpected: %s\nactual: %s\n' \
@@ -187,6 +243,8 @@ HIMESAN_RACE=1 ./scripts/verify.sh
printf '\n==> bounded compiler fuzz gates\n'
go test ./internal/compiler -run '^$' -fuzz '^FuzzCompileNeverPanics$' -fuzztime=20s
go test ./internal/compiler -run '^$' -fuzz '^FuzzGoDelimiterNeverPanics$' -fuzztime=20s
go test ./internal/lsp -run '^$' -fuzz '^FuzzFrameReaderNeverPanics$' -fuzztime=20s
go test ./internal/lsp -run '^$' -fuzz '^FuzzDocumentPositionNeverPanics$' -fuzztime=20s
printf '\n==> vulnerability scan (pinned golang.org/x/vuln v1.6.0)\n'
go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...
@@ -256,4 +314,8 @@ else
printf '\nBeta technical publication preflight passed. This does not establish RC/final launch evidence or production stability.\n'
fi
if (( compiler_only == 1 )); then
printf 'No tag, push, publication, or deployment was performed for %s; runtime remains %s.\n' "$version" "$runtime_tag"
else
printf 'No tag, push, publication, or deployment was performed for %s / %s.\n' "$version" "$runtime_tag"
fi