diff --git a/CHANGELOG.md b/CHANGELOG.md index d3502d8..efb9d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/PUBLIC-SNAPSHOT.json b/PUBLIC-SNAPSHOT.json index 83429ab..2f1497a 100644 --- a/PUBLIC-SNAPSHOT.json +++ b/PUBLIC-SNAPSHOT.json @@ -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"} diff --git a/PUBLIC-SNAPSHOT.sha256 b/PUBLIC-SNAPSHOT.sha256 index b69bf1b..30b2b28 100644 --- a/PUBLIC-SNAPSHOT.sha256 +++ b/PUBLIC-SNAPSHOT.sha256 @@ -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 diff --git a/README.md b/README.md index c1a9841..e0d1e8c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/RELEASE.md b/RELEASE.md index ac86d54..9278302 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 diff --git a/SECURITY.md b/SECURITY.md index cbe5f24..80049cf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/cmd/himesan/main.go b/cmd/himesan/main.go index b013791..9cd4ae1 100644 --- a/cmd/himesan/main.go +++ b/cmd/himesan/main.go @@ -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": @@ -116,10 +119,11 @@ func runVersion(args []string, stdout, stderr io.Writer) int { return 2 } information := struct { - Compiler string `json:"compiler"` - RuntimeABI string `json:"runtime_abi"` - Go string `json:"go"` - }{Compiler: version.Compiler, RuntimeABI: version.RuntimeABI, Go: runtime.Version()} + Compiler string `json:"compiler"` + RuntimeABI string `json:"runtime_abi"` + Go string `json:"go"` + 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.") diff --git a/cmd/himesan/main_test.go b/cmd/himesan/main_test.go index 988f3c1..a288707 100644 --- a/cmd/himesan/main_test.go +++ b/cmd/himesan/main_test.go @@ -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 { diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 8d45db2..361a6fe 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -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. diff --git a/docs/LANGUAGE_SERVER.md b/docs/LANGUAGE_SERVER.md new file mode 100644 index 0000000..87d9cd7 --- /dev/null +++ b/docs/LANGUAGE_SERVER.md @@ -0,0 +1,51 @@ + + +# 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. diff --git a/internal/compiler/analysis.go b/internal/compiler/analysis.go new file mode 100644 index 0000000..78232d4 --- /dev/null +++ b/internal/compiler/analysis.go @@ -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, + } +} diff --git a/internal/compiler/analysis_test.go b/internal/compiler/analysis_test.go new file mode 100644 index 0000000..a0caa35 --- /dev/null +++ b/internal/compiler/analysis_test.go @@ -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("\n
\n") + second := []byte("\n
\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 := "\n

card

\n" + malformed := "\n
\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 +} diff --git a/internal/compiler/discover.go b/internal/compiler/discover.go index 239f601..a70a001 100644 --- a/internal/compiler/discover.go +++ b/internal/compiler/discover.go @@ -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())) diff --git a/internal/compiler/model.go b/internal/compiler/model.go index a004eb0..4b441d8 100644 --- a/internal/compiler/model.go +++ b/internal/compiler/model.go @@ -59,17 +59,18 @@ type sourcePosition struct { } type sourceFile struct { - Path string - Mapping string - Package string - Name string - TypeParams string - Params string - Imports []sourceImport - Nodes []rendererNode - Source []byte - HeaderEnd int - AST *ast.File + Path string + Mapping string + Package string + Name string + TypeParams string + Params string + Imports []sourceImport + Nodes []rendererNode + Source []byte + HeaderEnd int + FunctionPos sourcePosition + AST *ast.File } type sourceImport struct { diff --git a/internal/compiler/parse.go b/internal/compiler/parse.go index ef985bc..e709931 100644 --- a/internal/compiler/parse.go +++ b/internal/compiler/parse.go @@ -114,16 +114,17 @@ func parseSource(path string, source []byte) (*sourceFile, []Diagnostic) { } file := &sourceFile{ - Path: path, - Mapping: filepath.ToSlash(filepath.Base(path)), - Package: parsedHeader.Package, - Name: parsedHeader.Name, - TypeParams: parsedHeader.TypeParams, - Params: parsedHeader.Params, - Imports: parsedHeader.Imports, - Source: source, - HeaderEnd: headerClose + 2, - AST: parsedHeader.AST, + Path: path, + Mapping: filepath.ToSlash(filepath.Base(path)), + Package: parsedHeader.Package, + Name: parsedHeader.Name, + TypeParams: parsedHeader.TypeParams, + Params: parsedHeader.Params, + Imports: parsedHeader.Imports, + Source: source, + HeaderEnd: headerClose + 2, + FunctionPos: parsedHeader.FunctionPos, + AST: parsedHeader.AST, } templateDiagnostics := tokenizeTemplate(file, source[headerClose+2:], headerClose+2, table) @@ -139,12 +140,13 @@ func isSpace(b byte) bool { } type parsedHeader struct { - Package string - Name string - TypeParams string - Params string - Imports []sourceImport - AST *ast.File + Package string + Name string + TypeParams string + Params string + Imports []sourceImport + FunctionPos sourcePosition + AST *ast.File } func parseHeader(path string, declarations []byte, sourceOffset int, table positionTable) (*parsedHeader, []Diagnostic) { @@ -254,12 +256,13 @@ func parseHeader(path string, declarations []byte, sourceOffset int, table posit } return &parsedHeader{ - Package: parsed.Name.Name, - Name: function.Name.Name, - TypeParams: typeParams, - Params: params, - Imports: imports, - AST: parsed, + Package: parsed.Name.Name, + Name: function.Name.Name, + TypeParams: typeParams, + Params: params, + Imports: imports, + FunctionPos: table.at(sourceOffset + functionPosition.Offset), + AST: parsed, }, diagnostics } diff --git a/internal/devserver/supervisor_test.go b/internal/devserver/supervisor_test.go index 71c8c9e..9059c68 100644 --- a/internal/devserver/supervisor_test.go +++ b/internal/devserver/supervisor_test.go @@ -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) } @@ -112,10 +117,13 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) { cfg.HealthPath = "/healthz" events := make(chan Event, 16) supervisor, err := New(Options{ - RootDir: root, - Config: cfg, - Generate: func(context.Context) error { return nil }, - OnEvent: func(event Event) { events <- event }, + RootDir: root, + Config: cfg, + Generate: func(context.Context) error { return nil }, + 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) { diff --git a/internal/lsp/features.go b/internal/lsp/features.go new file mode 100644 index 0000000..c0746be --- /dev/null +++ b/internal/lsp/features.go @@ -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("")) + if open >= 0 && open > close && bytes.HasPrefix(document.Text[open:offset], []byte("", InsertTextFormat: 2, SortText: "0-header"}, + {Label: "", Kind: 15, Detail: "Go statement", InsertText: "", InsertTextFormat: 2, SortText: "0-statement"}, + {Label: "", Kind: 15, Detail: "contextually escaped expression", InsertText: "", InsertTextFormat: 2, SortText: "0-expression"}, + {Label: "", Kind: 15, Detail: "typed component composition", InsertText: "", InsertTextFormat: 2, SortText: "0-component"}, + {Label: "", Kind: 15, Detail: "Hime-san template comment", InsertText: "", 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 "`** declares the Go package, imports, and one typed component signature. It must be the first non-whitespace content." + case "`** composes a typed `sando.Component` at an HTML content boundary. It is not template inheritance." + case "`** renders an expression through the helper selected by Hime-san's inferred HTML output context." + case "`** is a Hime-san comment. It emits no bytes and cannot change HTML parser state." + case "`** 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 closeRelative < 0 || open+closeRelative+2 < offset { + return "", 0, 0 + } + end := open + closeRelative + 2 + marker := " 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 +} diff --git a/internal/lsp/protocol.go b/internal/lsp/protocol.go new file mode 100644 index 0000000..fd903fa --- /dev/null +++ b/internal/lsp/protocol.go @@ -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"` +} diff --git a/internal/lsp/protocol_test.go b/internal/lsp/protocol_test.go new file mode 100644 index 0000000..0f832f3 --- /dev/null +++ b/internal/lsp/protocol_test.go @@ -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) + } + }) +} diff --git a/internal/lsp/security_test.go b/internal/lsp/security_test.go new file mode 100644 index 0000000..f6f6f8a --- /dev/null +++ b/internal/lsp/security_test.go @@ -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 + }) + } +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go new file mode 100644 index 0000000..f05bc05 --- /dev/null +++ b/internal/lsp/server.go @@ -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) +} diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go new file mode 100644 index 0000000..e65ce3a --- /dev/null +++ b/internal/lsp/server_test.go @@ -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, "\n

\n") + writeTestFile(t, badgePath, "\n\n") + + client, done := newProtocolClient(t, root) + homeURI := pathToURI(homePath) + client.waitDiagnostics(t, homeURI, "") + overlay := "\n

😀

\n\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 ?>

") + 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, "", "
", 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, "\n

no

\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("\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:]) +} diff --git a/internal/lsp/workspace.go b/internal/lsp/workspace.go new file mode 100644 index 0000000..2bd3f61 --- /dev/null +++ b/internal/lsp/workspace.go @@ -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, + } +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 7b542ef..5b7c138 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -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 { diff --git a/scripts/release-check.sh b/scripts/release-check.sh index ca57a8e..1dbe9fc 100755 --- a/scripts/release-check.sh +++ b/scripts/release-check.sh @@ -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,14 +23,20 @@ EOF } version='' +runtime_tag='' public_release=0 while (( $# > 0 )); do case "$1" in - --version) - [[ $# -ge 2 ]] || { usage >&2; exit 2; } - version=$2 - shift 2 - ;; + --version) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + 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 -printf 'No tag, push, publication, or deployment was performed for %s / %s.\n' "$version" "$runtime_tag" +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